Compare commits

..

18 Commits

Author SHA1 Message Date
0563e2c162 fix: correct Base64 encoding for 2-byte remainder
The original code used separate shifts for each byte instead of combining
them into a single 24-bit value first, producing wrong output for credentials
with lengths that leave 2-byte remainders (e.g. 'user:pass').
2026-07-23 21:13:24 +01:00
5805a4f7a4 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.
2026-07-23 20:56:56 +01:00
23d95e957c feat: CalDAV credential persistence in Secret Service
Move CalDAV username/password from in-memory config to Secret Service
(gnome-keyring). CalDAVSyncProvider now loads credentials at sync time
and erases them on disconnect. OAuthSecretStore extended with
store/load/erase_caldav_credential methods using a separate libsecret
schema.
2026-07-23 20:39:16 +01:00
5efcfdafb9 docs: add usage and configuration guide
Document keyboard controls, calendar import/export, Office 365 and CalDAV
setup, environment variables, troubleshooting, and file locations.
2026-07-23 20:10:03 +01:00
d2191094b8 docs: update README for 0.1 features and CalDAV support 2026-07-23 20:07:59 +01:00
1a69950f93 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.
2026-07-23 20:01:28 +01:00
2dab5b97b6 feat: CalDAV foundation — HTTP methods and XML parser
Add PROPFIND and REPORT to HttpMethod enum for CalDAV protocol support.
Implement minimal XML parser (caldav_xml.hpp) for CalDAV response parsing:
namespaced tags, attributes, CDATA, entity references, self-closing elements.
Bounded by max depth and size to prevent allocation bombs.
2026-07-23 19:48:38 +01:00
3b73badf5d feat: RECURRENCE-ID parsing, serialization, and round-trip support
Add recurrence_id field to Event domain model. Parse and serialize
RECURRENCE-ID from/to iCalendar files. Override events (with
RECURRENCE-ID but no RRULE) are safe to rewrite. Save validation
rejects events with both RECURRENCE-ID and RRULE.
2026-07-23 18:55:31 +01:00
6c1f58ffb0 feat: recurrence rule editing in EventEditor and ordinal BYDAY support
Add recurrence_frequency, recurrence_interval, recurrence_count, and
recurrence_by_weekday editor fields. Frequency cycles with Space.
BYDAY input accepts comma-separated names and ordinal prefixes (1MO).

Extend RecurrenceRule::by_weekday from std::chrono::weekday to ByWeekday
{ordinal, day} struct. Parser accepts ordinal BYDAY (+1MO, -1FR).
Remove strict BY*/FREQ pairing constraint to match RFC 5545.
2026-07-23 18:45:50 +01:00
40fc4651cb feat: screen-reader-friendly linear view (L key)
Plain-text appointment list with numbered indicators, no box-drawing
characters, and explicit labels. Reuses agenda data and navigation.
Binds to uppercase L to preserve vim-style hjkl grid navigation.
2026-07-23 18:24:06 +01:00
a159933baa refactor: shared Unicode display-width utility with grapheme cluster support
Consolidate two inconsistent codepoint_width implementations from
Screen.cpp and EventEditor.cpp into nocal/tui/unicode.hpp. The unified
table covers all CJK, Hangul, emoji, symbol, and combining mark ranges.
display_width and fit_text handle grapheme clusters (ZWJ sequences,
skin-tone/hair modifiers, regional indicator pairs, variation selectors)
so calendar text with emoji or East Asian characters renders correctly.
2026-07-23 18:03:33 +01:00
Bernardo Magri
673a2c66c9 feat: embedded VTIMEZONE parsing, round-trip serialization 2026-07-23 16:37:22 +01:00
725e48569e 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
2026-07-22 21:49:26 +01:00
74c798e7aa feat(sync): add Microsoft account connect CLI
nocal account connect microsoft runs the PKCE browser flow, reads /me,
upserts the deterministic account row, and persists tokens to Secret
Service as the final connected-state gate. Account IDs are hashed from
the remote subject so reconnect is idempotent, and a secret-store
failure leaves the same coherent state as a disconnect. Ships a
placeholder client ID with NOCAL_MICROSOFT_CLIENT_ID override until a
real Entra registration exists.
2026-07-21 18:44:47 +01:00
feec691535 docs: switch coder subagent to deepseek-v4-flash 2026-07-20 23:00:03 +01:00
5affa4d0cd docs: document coder subagent delegation setup 2026-07-20 22:51:28 +01:00
6d9e7b0e3e feat(tui): redesign chrome with unified key hints and help overlay
Introduce one key-hint grammar across every view: bold key, dim label,
priority-ordered hints that drop from the end instead of ellipsizing.
The month footer becomes a single split status bar with a friendly
selected date, the '?' mega-line becomes a real keyboard shortcut
panel, and the editor, agenda, search, picker, detail, and delete
frames share the same chips. Notifications carry check/warning marks.
Adds a live PTY smoke test covering view interaction and terminal
state restoration on q and Ctrl-C exits.
2026-07-20 22:51:28 +01:00
23bcb20ab5 feat(sync): add provider contracts and OAuth token broker
Draft the generic SyncProvider/SyncObserver contracts and implement
OAuthTokenBroker: valid access tokens with a five-minute refresh skew,
rotation persisted before use so a new refresh token is never lost,
400/401 refresh rejections erase the secret and surface
AccountDisconnected, and no credential material in error messages.
2026-07-20 22:50:54 +01:00
53 changed files with 7779 additions and 354 deletions

45
.opencode/agent/coder.md Normal file
View File

@@ -0,0 +1,45 @@
---
description: Implements bounded, well-specified coding slices for nocal. Owns an explicit file set, writes C++20 with deterministic tests, compiles with strict warnings, runs the relevant test binaries, and reports exact checks. Use for delegated implementation only — not for architecture decisions, cross-cutting changes, or review.
mode: subagent
model: openrouter/deepseek/deepseek-v4-flash
permission:
edit: allow
bash: allow
---
You are a coding agent for the nocal repository. You implement bounded slices
assigned by the lead agent. You do not redefine architecture, expand scope, or
review your own work for completion.
## Rules
- Stay strictly inside the file and directory set the lead assigns. Preserve
unrelated user changes. Touch nothing outside your scope.
- C++20, existing code style, minimal precise edits. No new runtime
dependencies without explicit lead approval.
- Add or update deterministic tests for the behavior you implement (injected
fakes; no network, wall-clock, or randomness).
- No git mutations (commit, push, reset, rebase, checkout). Read-only git
inspection is fine.
## Verification before reporting back
Run from the repository root and report the exact commands and outcomes:
1. `nix-shell --run 'meson compile -C build'` — must compile cleanly.
2. The specific test binaries for your slice, e.g.
`nix-shell --run 'meson test -C build --print-errorlogs <name>'`.
3. If the lead asked for warning-as-error or sanitizer checks, run exactly
what was requested and paste the tail of any failure.
## Report back
Your final message must state:
- Files changed and a one-line summary per file.
- The exact commands you ran and whether each passed.
- Limitations, unhandled edge cases, and any mismatch with the shared
contracts you were given.
Never claim completion based on visual inspection or intent. If a check failed
and you could not fix it, say so explicitly.

View File

@@ -37,6 +37,13 @@ pass is not sufficient evidence. Small, tightly coupled fixes may be completed
directly by the lead when delegation would create more coordination cost than directly by the lead when delegation would create more coordination cost than
implementation value. implementation value.
## Concrete delegation setup (nocal)
A coding subagent is configured at `.opencode/agent/coder.md` with model
`openrouter/deepseek/deepseek-v4-flash`. It owns bounded implementation slices assigned
by the lead; the lead retains shared contracts, integration, review, and final
verification. After any config change, restart opencode to reload agents.
## Delegation protocol ## Delegation protocol
Before dispatching implementation work, the lead must: Before dispatching implementation work, the lead must:

View File

@@ -4,12 +4,12 @@ Nocal is a fast, local-first C++20 month calendar designed for the Linux
terminal and Hyprland. It presents appointments inside a six-week month grid and terminal and Hyprland. It presents appointments inside a six-week month grid and
inherits its colors from your terminal theme. inherits its colors from your terminal theme.
This repository currently contains the first local-calendar vertical slice: This repository contains a complete local calendar with guarded atomic
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms, iCalendar persistence, recurrence rule editing, recurrence-ID overrides,
a responsive ANSI terminal UI with month and agenda views, explicit safe embedded VTIMEZONE round-trip, and Unicode display-width handling.
calendar import/export, tests, and Linux launch integration. A provider-neutral Remote synchronization is supported for Microsoft 365 (Office 365) and
SQLite cache now provides the durable boundary required before CalDAV servers (Nextcloud, iCloud, Radicale, etc.). Google Calendar support
Office 365 or any other remote provider is connected. See is planned. See
[the product specification](docs/PRODUCT.md), [the product specification](docs/PRODUCT.md),
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md). [architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
@@ -27,14 +27,15 @@ the month selection. Press `c` to open the calendar picker; arrows or
`j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press `j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press
`a` to add an appointment. Focus an `a` to add an appointment. Focus an
appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and
`Shift-Tab` (or arrows) between fields, `Space` for the all-day toggle, `Shift-Tab` (or arrows) between fields, `Space` for the all-day toggle and
`Ctrl-S` to save, and `Esc` to cancel. Back in the calendar, `u` undoes the recurrence frequency cycling, `Ctrl-S` to save, and `Esc` to cancel. Back in
last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles, the calendar, `u` undoes the last successful mutation and `Ctrl-R` redoes it.
descriptions, locations, and calendar IDs. Search results use arrows or Press `/` to search titles, descriptions, locations, and calendar IDs. Search
`j`/`k`; `Enter` jumps to the exact occurrence and `Esc` returns to the month. results use arrows or `j`/`k`; `Enter` jumps to the exact occurrence and `Esc`
The calendar picker and search are also available over the agenda with `c` and returns to the month. The calendar picker and search are also available over
`/`. Add, edit, and delete remain month-view workflows. the agenda with `c` and `/`. Add, edit, and delete remain month-view workflows.
Press `?` for help and `q` to quit. Press `L` for a screen-reader-friendly linear view. Press `?` for help and
`q` to quit.
## Build and run ## Build and run
@@ -182,6 +183,12 @@ them; omission is not mislabeled as a remote deletion tombstone. This avoids
beta and undocumented delta routes, at the cost of refetching each secondary beta and undocumented delta routes, at the cost of refetching each secondary
window instead of incrementally updating it. window instead of incrementally updating it.
There is still no account UI/CLI orchestration or live Microsoft credential Account setup begins with `nocal account connect microsoft`: it runs the
integration, and remote writes remain out of scope. Account setup and PKCE browser flow, reads `/me`, upserts the deterministic account row, and
orchestration are next. stores the tokens in the Secret Service keyring. This build ships a placeholder
client ID; live connect requires a real mobile/desktop Entra registration
supplied via `NOCAL_MICROSOFT_CLIENT_ID`.
CalDAV is supported for Nextcloud, Radicale, and other standards-compliant
servers. See [usage and configuration](docs/USAGE.md) for setup instructions
and keyboard shortcuts.

View File

@@ -288,6 +288,16 @@ This design avoids beta and undocumented per-calendar delta routes. Its
tradeoff is bandwidth: every secondary window is fetched completely on each tradeoff is bandwidth: every secondary window is fetched completely on each
refresh, while the primary calendar remains incremental. refresh, while the primary calendar remains incremental.
There is no account UI or CLI orchestration and no live Microsoft credential Account setup now has a CLI entry point, `nocal account connect microsoft`.
integration yet; tests use injected transports and credentials. Remote writes It composes the desktop OAuth adapters, libcurl transport, token parsing, one
remain out of scope. Account setup and orchestration are next. `/me` identity read, the sync cache, and the Secret Service store in a fixed
order: the account row is upserted first, and the versioned secret payload is
stored last as the connected-state gate. A secret-store failure therefore
leaves the same coherent state as a disconnect, and reconnecting is idempotent
because the local account id is a deterministic hash of the remote subject.
This build carries a placeholder client ID; a real Entra registration is
supplied through the `NOCAL_MICROSOFT_CLIENT_ID` environment variable (a
public client ID, never a secret). There is no account TUI and no sync
orchestration yet; tests use injected transports and credentials. Remote
writes remain out of scope. Generic `SyncProvider` integration and observable
sync status are next.

View File

@@ -93,10 +93,35 @@ Completed secondary-calendar stable-v1 reconciliation:
- Overlapping windows retain events until their last membership is removed - Overlapping windows retain events until their last membership is removed
- No beta or undocumented per-calendar delta dependency - No beta or undocumented per-calendar delta dependency
Completed provider-boundary contracts and token broker:
- Generic `SyncProvider`/`SyncObserver` contracts for account connect, sync,
and disconnect with staged progress events
- `OAuthTokenBroker` returning valid per-operation access tokens with a
five-minute refresh skew
- Refresh rotation persisted before use so a new refresh token is never lost;
transient refresh failures leave stored credentials untouched
- 400/401 refresh rejections erase the stored secret and surface
`AccountDisconnected` for re-authentication
- Credential material never appears in exception messages
Completed account setup (CLI):
- `nocal account connect microsoft` runs the PKCE browser flow, reads `/me`,
upserts the deterministic account row, and persists tokens to Secret
Service as the final connected-state gate
- Deterministic local account IDs hashed from the remote subject make
reconnect idempotent; a secret-store failure leaves the same coherent state
as a disconnect
- Placeholder client ID with `NOCAL_MICROSOFT_CLIENT_ID` override until a
real Entra mobile/desktop registration exists
- Deterministic failure-path tests: rejection, token errors, identity
failure, storage failure, and credential redaction in error messages
Next provider-boundary slices: Next provider-boundary slices:
- Account setup and CLI/TUI orchestration with live credential integration - TUI account orchestration and generic `SyncProvider` integration with
- Generic `SyncProvider` integration and observable sync status observable sync status
## 0.3 — CalDAV ## 0.3 — CalDAV
@@ -113,9 +138,10 @@ Next provider-boundary slices:
The Graph read boundary uses stable v1 delta for the primary calendar and The Graph read boundary uses stable v1 delta for the primary calendar and
complete stable-v1 calendar views for secondary calendars; it deliberately complete stable-v1 calendar views for secondary calendars; it deliberately
avoids beta and undocumented per-calendar delta routes. No account UI/CLI avoids beta and undocumented per-calendar delta routes. Account connect exists
orchestration or live Microsoft credential integration exists yet, and remote as a CLI command with live credential integration through Secret Service, but
writes remain out of scope. there is no account TUI or sync orchestration yet, and remote writes remain
out of scope.
## 1.0 — distribution quality ## 1.0 — distribution quality

296
docs/USAGE.md Normal file
View File

@@ -0,0 +1,296 @@
# Usage and Configuration
## Quick Start
```sh
# Build
nix-shell --run 'meson setup build && meson compile -C build'
# Run with demo appointments
./build/nocal --demo
# Run with your calendar file
./build/nocal ~/.local/share/nocal/calendar.ics
```
Nocal reads `$XDG_DATA_HOME/nocal/calendar.ics` by default (falls back to
`~/.local/share/nocal/calendar.ics`). Pass a different `.ics` path as the
positional argument.
## Keyboard Controls
### Month View
| Key | Action |
|---|---|
| `Arrow keys` / `h j k l` | Navigate between days |
| `PageUp` / `PageDown` / `p` / `n` | Change month |
| `t` | Jump to today |
| `Enter` (on day with events) | Open appointment reader |
| `Tab` / `Shift-Tab` | Move focus through appointments on a day |
| `a` | Add appointment |
| `e` (on focused event) | Edit appointment |
| `d` (on focused event) | Delete appointment |
| `g` | Open 42-day agenda |
| `c` | Calendar picker (toggle calendar visibility) |
| `/` | Search events |
| `L` | Linear view (screen-reader friendly) |
| `u` | Undo last mutation |
| `Ctrl-R` | Redo |
| `?` | Help |
| `q` | Quit |
### Agenda View
| Key | Action |
|---|---|
| `j` / `k` / `Arrow keys` | Navigate occurrences |
| `Tab` / `Shift-Tab` | Move between day and event |
| `Enter` | Return to month at selected occurrence |
| `PageUp` / `PageDown` / `p` / `n` | Shift agenda by 42 days |
| `g` / `Esc` | Return to month without changing selection |
| `c` | Calendar picker |
| `/` | Search |
### Appointment Reader
| Key | Action |
|---|---|
| `Arrow keys` / `h j k l` / `Tab` | Browse day's appointments |
| `Esc` | Return to month view |
### Editor Forms
| Key | Action |
|---|---|
| `Tab` / `Shift-Tab` / `Arrows` | Move between fields |
| `Space` | Toggle all-day / cycle recurrence frequency |
| `Ctrl-S` | Save |
| `Esc` | Cancel |
## Calendar File Format
Nocal uses standard iCalendar (RFC 5545) files. Create your calendar:
```sh
mkdir -p ~/.local/share/nocal
touch ~/.local/share/nocal/calendar.ics
```
### Importing Events
```sh
# Merge source into target
nocal --import source.ics --calendar target.ics
# Export validated calendar
nocal --export destination.ics --calendar source.ics
```
### Supported Recurrence
Nocal handles: daily, weekly, monthly, and yearly rules with interval, count or
until, common BYDAY/BYMONTH selectors, floating times, UTC, and system IANA
TZID names. Events with unsupported features remain browsable but read-only.
### Backup and Recovery
Every successful replacement keeps `calendar.ics.bak`. To restore:
```sh
nocal --restore-backup ~/.local/share/nocal/calendar.ics
```
## Remote Synchronization
Nocal supports remote providers through the SQLite sync cache, separate from
the interactive `.ics` file. Credentials are stored in the system Secret
Service (gnome-keyring, kde-wallet, or secret-service).
### Office 365 (Microsoft 365)
Nocal uses the OAuth 2.0 authorization code flow with PKCE.
**Prerequisites:**
- A Microsoft Entra (Azure AD) application registration
- Mobile & desktop application type
- Redirect URI: `http://localhost/nocal/oauth/callback`
- Delegated permissions: `User.Read`, `Calendars.Read`
**Setup:**
```sh
# Set your client ID (required — placeholder is not functional)
export NOCAL_MICROSOFT_CLIENT_ID="your-client-id-here"
# Connect account (opens browser for OAuth consent)
nocal account connect microsoft
# Sync
nocal sync
```
The client ID is required because Nocal cannot embed one — each Microsoft
application registration is tenant-scoped. Register at
https://portal.azure.com → Azure Active Directory → App registrations.
**What happens during connect:**
1. Nocal opens your browser for Microsoft OAuth consent
2. A loopback server receives the authorization code
3. Nocal exchanges the code for tokens with PKCE verification
4. Tokens are stored in Secret Service
5. Account identity is fetched from `/me`
6. The account is upserted in the sync cache
**Token storage:** Tokens live only in Secret Service, never in SQLite, logs,
or environment variables. If your keyring is locked, sync will fail until
unlocked.
### CalDAV (Nextcloud, Radicale, iCloud, etc.)
Nocal supports CalDAV servers via basic authentication.
**Prerequisites:**
- A CalDAV server (Nextcloud, Radicale, etc.)
- Username and password
**Setup:**
```sh
# Connect CalDAV account
nocal account connect caldav \
--server https://cloud.example.com/remote.php/dav \
--user your_username \
--pass your_password
# Sync
nocal sync
```
**What happens during connect:**
1. Nocal sends PROPFIND to discover your principal URL
2. Nocal discovers the calendar-home-set
3. Nocal lists all calendars under your home-set
4. Calendars are upserted in the sync cache
**What happens during sync:**
1. Nocal uses CalDAV sync-collection for delta sync
2. Only changed events are fetched
3. Events are parsed from iCalendar and stored in the sync cache
4. Sync tokens are persisted for incremental updates
**Server compatibility:**
- **Nextcloud**: Full support. Use `https://cloud.example.com/remote.php/dav`
as the server URL.
- **Radicale**: Full support.
- **iCloud**: Requires a CalDAV-compatible client (e.g., via Apple's CalDAV
endpoint or a bridge).
- **SOGo**: Supported.
- **SABREDAV-based servers**: Generally supported.
### Google Calendar
Google Calendar support is planned. Google does not offer CalDAV for writes,
so Nocal will use the Google Calendar API directly.
**Expected workflow:**
1. OAuth 2.0 flow with PKCE (same as Microsoft)
2. `calendar.events.list` with sync token for delta sync
3. Local caching in the sync database
## Configuration
### Environment Variables
| Variable | Purpose |
|---|---|
| `NO_COLOR` | Disable ANSI colors (keeps text readable) |
| `XDG_DATA_HOME` | Base directory for calendar files (default: `~/.local/share`) |
| `NOCAL_MICROSOFT_CLIENT_ID` | OAuth client ID for Microsoft connect |
### Week Start Day
```sh
nocal --week-start monday # default
nocal --week-start sunday
# Any day: monday, tuesday, wednesday, thursday, friday, saturday, sunday
```
This is display-only and does not affect stored events or recurrence.
### Print Mode
```sh
nocal --print # Non-interactive frame output
```
## Hyprland Integration
For popup calendar binding, see [Hyprland integration](HYPRLAND.md).
```sh
nocal --print | some-renderer
```
The `nocal-hyprland` script provides a ready-made binding.
## File Locations
| Path | Purpose |
|---|---|
| `~/.local/share/nocal/calendar.ics` | Default local calendar |
| `~/.local/share/nocal/calendar.ics.bak` | Backup of previous version |
| `~/.local/share/nocal/nocal.db` | Sync cache (SQLite) |
| Secret Service | OAuth tokens and credentials |
## Troubleshooting
### Sync fails with "authentication failed"
Ensure your keyring is unlocked. Check with:
```sh
secret-tool lookup nocal account <account-id>
```
If the token is missing, reconnect:
```sh
nocal account connect microsoft # or caldav
nocal sync
```
### Calendar appears read-only
Your calendar file may contain unsupported features (RDATE periods, ordinal
BYDAY, embedded VTIMEZONE, alarms, etc.). These are browsable but not editable
to prevent data loss. Use `--export` to create a clean copy.
### Import rejected
Import validates before writing. Check:
- No empty UIDs
- No duplicate UIDs
- No invalid recurrence intervals
- UID collisions with different events are rejected
### Events not showing after sync
Check which calendars are visible with `c` (calendar picker). Synced calendars
may be hidden by default. Toggle visibility with `Enter` in the picker.
## Development
```sh
# Build
nix-shell --run 'meson setup build && meson compile -C build'
# Run all tests
nix-shell --run 'meson test -C build --print-errorlogs'
# Run specific test
nix-shell --run 'meson test -C build nocal:caldav-xml'
```
See [architecture](ARCHITECTURE.md) for design decisions and [roadmap](ROADMAP.md)
for planned features.

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <chrono> #include <chrono>
#include <cstdint>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -14,12 +15,20 @@ enum class TimeBasis { floating, utc, zoned };
enum class RecurrenceFrequency { daily, weekly, monthly, yearly }; enum class RecurrenceFrequency { daily, weekly, monthly, yearly };
// BYDAY selector. For non-ordinal: use {0, weekday}. For ordinal: {n, weekday}.
struct ByWeekday {
int ordinal{0}; // 0 = non-ordinal; 1-5 or -5 to -1 = ordinal position
std::chrono::weekday day;
bool operator==(const ByWeekday&) const = default;
};
struct RecurrenceRule { struct RecurrenceRule {
RecurrenceFrequency frequency{RecurrenceFrequency::daily}; RecurrenceFrequency frequency{RecurrenceFrequency::daily};
unsigned interval{1}; unsigned interval{1};
std::optional<unsigned> count; std::optional<unsigned> count;
std::optional<TimePoint> until; std::optional<TimePoint> until;
std::vector<std::chrono::weekday> by_weekday; std::vector<ByWeekday> by_weekday;
std::vector<int> by_month_day; std::vector<int> by_month_day;
std::vector<unsigned> by_month; std::vector<unsigned> by_month;
@@ -41,6 +50,7 @@ struct Event {
std::optional<RecurrenceRule> recurrence; std::optional<RecurrenceRule> recurrence;
std::vector<TimePoint> recurrence_additions; std::vector<TimePoint> recurrence_additions;
std::vector<TimePoint> recurrence_exceptions; std::vector<TimePoint> recurrence_exceptions;
std::optional<TimePoint> recurrence_id;
}; };
struct Calendar { struct Calendar {
@@ -61,4 +71,11 @@ struct Calendar {
return event.recurrence.has_value() || !event.recurrence_additions.empty(); return event.recurrence.has_value() || !event.recurrence_additions.empty();
} }
// True when this event is a detached override of a recurring series
// (has a RECURRENCE-ID but is not itself a recurring series).
[[nodiscard]] constexpr bool is_override(const Event& event) noexcept
{
return event.recurrence_id.has_value();
}
} // namespace nocal } // namespace nocal

View File

@@ -27,6 +27,15 @@ private:
friend class IcsStore; friend class IcsStore;
}; };
// Embedded VTIMEZONE definition preserved for round-trip serialization.
struct VTimezoneDefinition {
std::string tzid;
// Complete raw content between BEGIN:VTIMEZONE and END:VTIMEZONE (exclusive
// of the framing lines). Empty when the definition was not parsed from a
// source calendar.
std::string raw;
};
struct LoadResult { struct LoadResult {
std::vector<Event> events; std::vector<Event> events;
std::vector<std::string> warnings; std::vector<std::string> warnings;
@@ -36,6 +45,9 @@ struct LoadResult {
// Exact bytes that produced this result. Copies share immutable storage and // Exact bytes that produced this result. Copies share immutable storage and
// can be supplied to save/restore as an optimistic concurrency token. // can be supplied to save/restore as an optimistic concurrency token.
FileRevision revision; FileRevision revision;
// Embedded VTIMEZONE definitions from the source calendar, preserved for
// round-trip serialization. Populated only by load().
std::vector<VTimezoneDefinition> vtimezones;
}; };
class IcsStore { class IcsStore {
@@ -49,9 +61,11 @@ public:
// requests durable filesystem flushes. I/O failures before replacement are // requests durable filesystem flushes. I/O failures before replacement are
// reported as std::runtime_error. If expected is present, the destination // reported as std::runtime_error. If expected is present, the destination
// must still contain the exact bytes represented by that revision. // must still contain the exact bytes represented by that revision.
// Supply vtimezones to preserve embedded timezone definitions.
static FileRevision save( static FileRevision save(
const std::filesystem::path& path, std::span<const Event> events, const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected = std::nullopt); std::optional<FileRevision> expected = std::nullopt,
std::span<const VTimezoneDefinition> vtimezones = {});
// Restores the exact bytes in <path>.bak without changing the backup. // Restores the exact bytes in <path>.bak without changing the backup.
static FileRevision restore_backup( static FileRevision restore_backup(

View File

@@ -0,0 +1,21 @@
#pragma once
#include <string>
#include <string_view>
namespace nocal::sync {
// CalDAV account configuration (credentials loaded from Secret Service at runtime).
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,41 @@
#pragma once
#include "nocal/sync/caldav_account.hpp"
#include "nocal/sync/http.hpp"
#include "nocal/sync/provider.hpp"
#include "nocal/sync/secret_store.hpp"
#include <memory>
#include <string>
namespace nocal::storage {
class SyncCache;
}
namespace nocal::sync {
// CalDAV server configuration (no credentials — those live in Secret Service).
struct CalDAVServerConfig {
std::string server_url;
std::string account_id;
std::string display_name;
};
class CalDAVSyncProvider final : public SyncProvider {
public:
CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache,
OAuthSecretStore& secret_store, CalDAVServerConfig 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_;
OAuthSecretStore& secret_store_;
CalDAVServerConfig config_;
};
} // namespace nocal::sync

View File

@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace nocal::sync {
// A parsed XML element.
struct XmlElement {
std::string tag;
std::vector<std::pair<std::string, std::string>> attributes;
std::string text;
std::string cdata;
std::vector<XmlElement> children;
};
// Parse an XML document string into a tree of XmlElement.
// Returns the root element. Throws std::runtime_error on parse failure.
// Bounded by max_size to prevent allocation bombs.
[[nodiscard]] XmlElement parse_xml(
std::string_view document, std::size_t max_size = 4 * 1024 * 1024);
// Find all children with the given tag name.
[[nodiscard]] std::vector<const XmlElement*> find_children(
const XmlElement& parent, std::string_view tag);
// Get first child text or empty string.
[[nodiscard]] std::string_view get_child_text(
const XmlElement& parent, std::string_view tag);
// Get attribute value or empty string.
[[nodiscard]] std::string_view get_attribute(
const XmlElement& elem, std::string_view name);
} // namespace nocal::sync

View File

@@ -5,7 +5,7 @@
namespace nocal::sync { namespace nocal::sync {
enum class HttpMethod { get, post, put, patch, delete_ }; enum class HttpMethod { get, post, put, patch, delete_, propfind, report };
struct HttpHeader { struct HttpHeader {
std::string name; std::string name;

View File

@@ -0,0 +1,70 @@
#pragma once
#include "nocal/storage/sync_cache.hpp"
#include "nocal/sync/oauth.hpp"
#include <cstdint>
#include <functional>
#include <stdexcept>
#include <string>
#include <string_view>
namespace nocal::sync {
class HttpTransport;
class OAuthSecretStore;
// Placeholder used when no real Entra application registration is available.
// It is not a registered client; interactive connect with it fails at the
// authorization endpoint. Override with the NOCAL_MICROSOFT_CLIENT_ID
// environment variable. A client ID is public configuration, never a secret.
inline constexpr std::string_view microsoft_client_id_placeholder =
"nocal-unregistered-client-id";
// Client ID used for interactive connect: the NOCAL_MICROSOFT_CLIENT_ID
// environment variable when set and non-empty, otherwise the placeholder.
[[nodiscard]] std::string default_microsoft_client_id();
// Public-desktop Microsoft OAuth configuration for the multitenant
// organizational and personal Microsoft account audience with delegated
// read-only scopes. The open-source binary never embeds a client secret.
[[nodiscard]] OAuthClientConfig microsoft_oauth_config(std::string client_id);
class MicrosoftAccountError final : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// Deterministic local account id derived from the stable remote subject, so
// reconnecting the same Microsoft account upserts the same cache row and
// reuses the same Secret Service key.
[[nodiscard]] std::string microsoft_account_id(std::string_view remote_subject);
// Runs one interactive Microsoft account connection:
// 1. authorization-code flow with PKCE S256 (one browser launch, one
// loopback callback, one token request) via authorize_with_pkce;
// 2. strict token parsing that requires an initial refresh token;
// 3. one /me identity read for the remote subject and display name;
// 4. the account row upserted into the sync cache;
// 5. the versioned token payload persisted to the Secret Service store.
//
// Invariants and failure semantics:
// - The account is connected only after step 5 succeeds; there is no
// plaintext fallback and no partial connected state.
// - Failure before step 4 leaves the cache and the secret store untouched.
// - Failure in step 5 leaves a cached account without credentials — the same
// coherent state that disconnecting produces. Reconnecting is idempotent
// because the account id is deterministic.
// - The access token is never retained after return, and error messages
// never contain credential material.
[[nodiscard]] storage::CachedAccount connect_microsoft_account(
const OAuthClientConfig& config,
BrowserLauncher& browser,
AuthorizationCallbackReceiver& receiver,
HttpTransport& transport,
EntropySource& entropy,
OAuthSecretStore& secret_store,
storage::SyncCache& cache,
const std::function<std::int64_t()>& clock);
} // namespace nocal::sync

View File

@@ -6,6 +6,7 @@
#include <cstdint> #include <cstdint>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view>
namespace nocal::sync { namespace nocal::sync {
@@ -28,6 +29,13 @@ public:
using std::runtime_error::runtime_error; using std::runtime_error::runtime_error;
}; };
// Validated /me identity. Fetched without persisting anything so account
// connect can derive the deterministic local account id before any cache write.
struct MicrosoftGraphIdentity {
std::string remote_subject;
std::string display_name;
};
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers // Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for // every calendar returned by /me/calendars. Stable v1.0 event delta is used for
// the primary calendar and complete calendarView reconciliation for secondary // the primary calendar and complete calendarView reconciliation for secondary
@@ -36,6 +44,10 @@ class MicrosoftGraphSync {
public: public:
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache); MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
// Requires delegated User.Read. Reads /me and returns the validated
// identity without touching the cache; the access token is not retained.
[[nodiscard]] MicrosoftGraphIdentity fetch_identity(std::string_view access_token) const;
// Requires delegated User.Read. A complete and validated /me response is // Requires delegated User.Read. A complete and validated /me response is
// persisted atomically; failures leave the cached account unchanged. // persisted atomically; failures leave the cached account unchanged.
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials); storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);

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

@@ -0,0 +1,56 @@
#pragma once
#include <functional>
#include <optional>
#include <string>
#include <string_view>
namespace nocal::sync {
enum class SyncStage {
authenticating,
refreshing_identity,
discovering_calendars,
syncing_calendar,
completed,
};
struct SyncProgressEvent {
SyncStage stage{SyncStage::authenticating};
std::string account_id;
std::string calendar_name;
std::size_t calendars_completed{0};
std::size_t calendars_total{0};
};
class SyncObserver {
public:
virtual ~SyncObserver() = default;
virtual void on_sync_progress(const SyncProgressEvent& event) = 0;
};
struct ConnectedAccount {
std::string account_id;
std::string display_name;
};
class SyncProvider {
public:
virtual ~SyncProvider() = default;
[[nodiscard]] virtual std::string_view provider_id() const noexcept = 0;
// Interactive connect: runs provider auth, stores credentials, registers
// account. Must not leave partial connected state on failure.
virtual ConnectedAccount connect_account() = 0;
// Syncs one account's calendars and windows; reports progress via observer.
// May continue past individual calendar failures but reports them.
virtual void sync_account(std::string_view account_id, SyncObserver& observer) = 0;
// Removes credentials for the account. Cached provider data is left in place
// (deterministic IDs make re-add idempotent). Unknown account is an error.
virtual void disconnect_account(std::string_view account_id) = 0;
};
} // namespace nocal::sync

View File

@@ -9,6 +9,13 @@
namespace nocal::sync { namespace nocal::sync {
struct CalDAVCredential {
std::string username;
std::string password;
bool operator==(const CalDAVCredential&) const = default;
};
class SecretStoreError : public std::runtime_error { class SecretStoreError : public std::runtime_error {
public: public:
enum class Kind { enum class Kind {
@@ -34,6 +41,13 @@ public:
[[nodiscard]] virtual std::optional<OAuthTokens> load( [[nodiscard]] virtual std::optional<OAuthTokens> load(
std::string account_id, std::stop_token stop = {}) = 0; std::string account_id, std::stop_token stop = {}) = 0;
virtual void erase(std::string account_id, std::stop_token stop = {}) = 0; virtual void erase(std::string account_id, std::stop_token stop = {}) = 0;
virtual void store_caldav_credential(std::string account_id,
const CalDAVCredential& credential, std::stop_token stop = {}) = 0;
[[nodiscard]] virtual std::optional<CalDAVCredential> load_caldav_credential(
std::string account_id, std::stop_token stop = {}) = 0;
virtual void erase_caldav_credential(std::string account_id,
std::stop_token stop = {}) = 0;
}; };
// Synchronous libsecret adapter. Call only from a worker thread: Secret Service // Synchronous libsecret adapter. Call only from a worker thread: Secret Service
@@ -45,6 +59,13 @@ public:
[[nodiscard]] std::optional<OAuthTokens> load( [[nodiscard]] std::optional<OAuthTokens> load(
std::string account_id, std::stop_token stop = {}) override; std::string account_id, std::stop_token stop = {}) override;
void erase(std::string account_id, std::stop_token stop = {}) override; void erase(std::string account_id, std::stop_token stop = {}) override;
void store_caldav_credential(std::string account_id,
const CalDAVCredential& credential, std::stop_token stop = {}) override;
[[nodiscard]] std::optional<CalDAVCredential> load_caldav_credential(
std::string account_id, std::stop_token stop = {}) override;
void erase_caldav_credential(std::string account_id,
std::stop_token stop = {}) override;
}; };
} // namespace nocal::sync } // namespace nocal::sync

View File

@@ -0,0 +1,58 @@
#pragma once
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include <functional>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
namespace nocal::sync {
class OAuthSecretStore;
class HttpTransport;
class AccountDisconnected final : public std::runtime_error {
public:
explicit AccountDisconnected(std::string message)
: std::runtime_error(std::move(message)) {}
};
class OAuthTokenBroker {
public:
// Constructs a broker for one OAuth provider configuration.
// `clock` returns the current epoch seconds for expiry calculations.
// It must be monotonic-ish (system_clock is fine).
OAuthTokenBroker(const OAuthClientConfig& config,
HttpTransport& transport,
OAuthSecretStore& secret_store,
std::function<std::int64_t()> clock);
// Returns a valid access token for the account, refreshing and persisting
// the rotated tokens when the stored access token is expired or within
// 5 minutes of expiry.
//
// Failure semantics:
// - Transport error, 429, 5xx, timeout -> throws std::runtime_error;
// secret store is NOT touched.
// - 400/401 on refresh (invalid_grant, unauthorized_client, etc.) ->
// erases the secret from the store, throws AccountDisconnected
// (re-auth required).
// - Parsing/validation failure on refresh response -> throws
// std::runtime_error; secret is NOT erased (may be transient malformed
// response).
// - Secret store load failure (missing, locked, cancelled) -> throws
// std::runtime_error; no erase.
[[nodiscard]] OAuthTokens valid_tokens(std::string_view account_id);
private:
const OAuthClientConfig config_;
HttpTransport& transport_;
OAuthSecretStore& secret_store_;
std::function<std::int64_t()> clock_;
static constexpr std::int64_t refresh_skew_seconds = 300; // 5 min
};
} // namespace nocal::sync

View File

@@ -25,6 +25,11 @@ enum class EditorField : std::size_t {
all_day, all_day,
location, location,
notes, notes,
// Recurrence fields
recurrence_frequency, // none/daily/weekly/monthly/yearly
recurrence_interval, // number
recurrence_count, // number or empty for unlimited
recurrence_by_weekday, // comma-separated: MO,TU,WE,TH,FR
count, count,
}; };

View File

@@ -82,9 +82,12 @@ struct ScreenState {
std::size_t search_result_index{0}; std::size_t search_result_index{0};
std::string notification; std::string notification;
bool notification_is_error{false}; bool notification_is_error{false};
std::string sync_stage;
bool sync_is_error{false};
bool show_calendar_picker{false}; bool show_calendar_picker{false};
std::size_t calendar_index{0}; std::size_t calendar_index{0};
bool show_agenda{false}; bool show_agenda{false};
bool show_linear_view{false};
std::chrono::year_month_day agenda_start_day{ std::chrono::year_month_day agenda_start_day{
std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}}; std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}};
std::vector<AgendaItem> agenda_items; std::vector<AgendaItem> agenda_items;
@@ -136,6 +139,8 @@ enum class Action {
search, search,
agenda, agenda,
calendars, calendars,
linear_view,
sync,
quit, quit,
}; };

View File

@@ -1,13 +1,17 @@
#pragma once #pragma once
#include <functional> #include <functional>
#include <memory>
#include <mutex>
#include <optional> #include <optional>
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <thread>
#include <vector> #include <vector>
#include "nocal/domain/event.hpp" #include "nocal/domain/event.hpp"
#include "nocal/sync/provider.hpp"
#include "nocal/tui/EventEditor.hpp" #include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/Screen.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 // The Event vector is retained by reference so future editor commands can
// update the caller's model without changing the application boundary. // update the caller's model without changing the application boundary.
class TuiApp { class TuiApp : public sync::SyncObserver {
public: public:
using SaveCallback = std::function<void(std::span<const Event>)>; using SaveCallback = std::function<void(std::span<const Event>)>;
@@ -57,7 +61,28 @@ public:
return calendars_; 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: 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 { struct HistorySnapshot {
std::vector<Event> events; std::vector<Event> events;
std::chrono::year_month visible_month; std::chrono::year_month visible_month;
@@ -96,9 +121,12 @@ private:
void toggle_selected_calendar(); void toggle_selected_calendar();
void begin_agenda(); void begin_agenda();
void close_agenda(); void close_agenda();
void begin_linear_view();
void close_linear_view();
void refresh_agenda(); void refresh_agenda();
void shift_agenda(int direction); void shift_agenda(int direction);
void choose_agenda_item(); void choose_agenda_item();
void choose_linear_item();
[[nodiscard]] HistorySnapshot capture_history_snapshot() const; [[nodiscard]] HistorySnapshot capture_history_snapshot() const;
void restore_history_snapshot(HistorySnapshot snapshot); void restore_history_snapshot(HistorySnapshot snapshot);
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry); void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);

View File

@@ -0,0 +1,35 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
namespace nocal::tui {
struct DecodedCodepoint {
std::uint32_t value;
std::size_t length;
};
// Decode one UTF-8 codepoint at position `at` in `text`.
// Returns {0xfffd, 1} on error. Caller must ensure at < text.size().
[[nodiscard]] DecodedCodepoint decode_codepoint(std::string_view text, std::size_t at) noexcept;
// Terminal display width of a single codepoint: 0 (zero-width/combining), 1, or 2.
// Uses Unicode East Asian Width property with complete range tables.
[[nodiscard]] int codepoint_width(std::uint32_t value) noexcept;
// Display width of a string. Handles grapheme clusters:
// - Emoji ZWJ sequences → width 2
// - Emoji + skin tone / hair modifiers → width 2
// - Regional indicator pairs (flags) → width 2
// - Combining marks → absorbed by base
[[nodiscard]] int display_width(std::string_view text);
// Truncate to `width` display cells. If `ellipsis` is true and the text is
// truncated, appends U+2026. Right-pads with spaces to reach `width`.
// Never breaks a grapheme cluster.
[[nodiscard]] std::string fit_text(std::string_view text, int width, bool ellipsis = true);
} // namespace nocal::tui

View File

@@ -26,16 +26,24 @@ 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/curl_http.cpp', 'src/sync/curl_http.cpp',
'src/sync/desktop_oauth.cpp', 'src/sync/desktop_oauth.cpp',
'src/sync/microsoft_account.cpp',
'src/sync/microsoft_graph.cpp', 'src/sync/microsoft_graph.cpp',
'src/sync/microsoft_sync_provider.cpp',
'src/sync/oauth.cpp', 'src/sync/oauth.cpp',
'src/sync/oauth_tokens.cpp', 'src/sync/oauth_tokens.cpp',
'src/sync/secret_store.cpp', 'src/sync/secret_store.cpp',
'src/sync/token_broker.cpp',
'src/tui/Screen.cpp', 'src/tui/Screen.cpp',
'src/tui/EventEditor.cpp', 'src/tui/EventEditor.cpp',
'src/tui/Terminal.cpp', 'src/tui/Terminal.cpp',
'src/tui/TuiApp.cpp', 'src/tui/TuiApp.cpp',
'src/tui/unicode.cpp',
) )
nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc, nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc,
dependencies: [sqlite, libcrypto, libcurl, libsecret, nlohmann_json]) dependencies: [sqlite, libcrypto, libcurl, libsecret, nlohmann_json])
@@ -45,6 +53,11 @@ nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib,
nocal_executable = executable('nocal', 'src/main.cpp', nocal_executable = executable('nocal', 'src/main.cpp',
dependencies: nocal_dep, install: true) dependencies: nocal_dep, install: true)
util = meson.get_compiler('cpp').find_library('util', required: false)
pty_smoke = executable('tui-pty-smoke', 'tests/tui_pty_smoke.cpp',
dependencies: util)
test('tui-pty-smoke', pty_smoke, args: [nocal_executable], timeout: 60)
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp', domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('domain', domain_tests) test('domain', domain_tests)
@@ -72,6 +85,12 @@ test('desktop-oauth', desktop_oauth_tests)
oauth_token_tests = executable('oauth-token-tests', oauth_token_tests = executable('oauth-token-tests',
'tests/oauth_token_tests.cpp', dependencies: nocal_dep) 'tests/oauth_token_tests.cpp', dependencies: nocal_dep)
test('oauth-tokens', oauth_token_tests) test('oauth-tokens', oauth_token_tests)
token_broker_tests = executable('token-broker-tests',
'tests/token_broker_tests.cpp', dependencies: nocal_dep)
test('token-broker', token_broker_tests)
microsoft_account_tests = executable('microsoft-account-tests',
'tests/microsoft_account_tests.cpp', dependencies: nocal_dep)
test('microsoft-account', microsoft_account_tests)
secret_store_tests = executable('secret-store-tests', secret_store_tests = executable('secret-store-tests',
'tests/secret_store_tests.cpp', dependencies: nocal_dep) 'tests/secret_store_tests.cpp', dependencies: nocal_dep)
test('secret-store', shell, test('secret-store', shell,
@@ -82,16 +101,27 @@ test('microsoft-graph', microsoft_graph_tests)
microsoft_graph_security_tests = executable('microsoft-graph-security-tests', microsoft_graph_security_tests = executable('microsoft-graph-security-tests',
'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep) 'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep)
test('microsoft-graph-security', microsoft_graph_security_tests) test('microsoft-graph-security', microsoft_graph_security_tests)
microsoft_sync_provider_tests = executable('microsoft-sync-provider-tests',
'tests/microsoft_sync_provider_tests.cpp', dependencies: nocal_dep)
test('microsoft-sync-provider', microsoft_sync_provider_tests)
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp', tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('tui', tui_tests) test('tui', tui_tests)
tui_focus_tests = executable('tui-focus-tests', 'tests/tui_focus_tests.cpp', tui_focus_tests = executable('tui-focus-tests', 'tests/tui_focus_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('tui-focus', tui_focus_tests) test('tui-focus', tui_focus_tests)
editor_tests = executable('editor-tests', 'tests/editor_tests.cpp', editor_tests = executable('editor-tests',
dependencies: nocal_dep) 'tests/editor_tests.cpp', dependencies: nocal_dep)
test('editor', editor_tests) test('editor', editor_tests)
unicode_tests = executable('unicode-tests',
'tests/unicode_tests.cpp', dependencies: nocal_dep)
test('nocal:unicode', unicode_tests)
caldav_xml_tests = executable('caldav-xml-tests',
'tests/caldav_xml_tests.cpp', dependencies: nocal_dep)
test('caldav-xml', caldav_xml_tests)
test('cli-recovery', shell, test('cli-recovery', shell,
args: [files('tests/cli_recovery_test.sh'), nocal_executable]) args: [files('tests/cli_recovery_test.sh'), nocal_executable])
test('cli-transfer', shell, test('cli-transfer', shell,

View File

@@ -211,8 +211,8 @@ void append_occurrence(EventOccurrences& result, const Event& event,
!rule.by_month.empty()) { !rule.by_month.empty()) {
return false; return false;
} }
for (const auto weekday : rule.by_weekday) { for (const auto& bw : rule.by_weekday) {
if (!weekday.ok()) { if (!bw.day.ok()) {
return false; return false;
} }
} }
@@ -267,8 +267,8 @@ void append_occurrence(EventOccurrences& result, const Event& event,
1U); 1U);
} else { } else {
result.reserve(rule.by_weekday.size()); result.reserve(rule.by_weekday.size());
for (const auto value : rule.by_weekday) { for (const auto& bw : rule.by_weekday) {
result.push_back(value.iso_encoding() - 1U); result.push_back(bw.day.iso_encoding() - 1U);
} }
} }
std::sort(result.begin(), result.end()); std::sort(result.begin(), result.end());

View File

@@ -1,9 +1,20 @@
#include "nocal/domain/calendar_transfer.hpp" #include "nocal/domain/calendar_transfer.hpp"
#include "nocal/domain/date.hpp" #include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.hpp" #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"
#include "nocal/sync/secret_store.hpp"
#include "nocal/sync/token_broker.hpp"
#include "nocal/tui/Screen.hpp" #include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp" #include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
@@ -13,6 +24,7 @@
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <system_error>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -45,6 +57,203 @@ std::filesystem::path default_calendar_path()
return "calendar.ics"; return "calendar.ics";
} }
std::filesystem::path default_sync_cache_path()
{
if (const char* data_home = std::getenv("XDG_DATA_HOME");
data_home != nullptr && *data_home != '\0') {
return std::filesystem::path{data_home} / "nocal" / "sync.db";
}
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "sync.db";
}
return "sync.db";
}
int connect_microsoft_account()
{
const std::string client_id = nocal::sync::default_microsoft_client_id();
if (client_id == nocal::sync::microsoft_client_id_placeholder) {
std::cerr <<
"nocal: this build has no registered Microsoft application client ID.\n"
"Register a mobile and desktop public client with redirect URI\n"
"http://localhost/nocal/oauth/callback, then set NOCAL_MICROSOFT_CLIENT_ID.\n";
return EXIT_FAILURE;
}
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::XdgBrowserLauncher browser;
nocal::sync::LoopbackCallbackReceiver receiver;
nocal::sync::SystemEntropySource entropy;
nocal::sync::LibsecretOAuthSecretStore secrets;
const auto account = nocal::sync::connect_microsoft_account(
nocal::sync::microsoft_oauth_config(client_id), browser, receiver, transport,
entropy, secrets, cache, [] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
});
std::cout << "Connected " << account.display_name << " (" << account.id << ")\n";
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
int disconnect_account_command(nocal::storage::SyncCache& cache,
nocal::sync::OAuthSecretStore& secret_store,
std::string_view account_id)
{
// Verify account exists in cache
const auto snapshot = cache.snapshot();
const auto it = std::find_if(snapshot.accounts.begin(), snapshot.accounts.end(),
[&account_id](const nocal::storage::CachedAccount& a) { return a.id == account_id; });
if (it == snapshot.accounts.end()) {
throw std::invalid_argument("unknown account: " + std::string{account_id});
}
// 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.
std::cout << "Disconnected " << it->display_name << " (" << account_id << ")\n";
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
int list_accounts_command(const nocal::storage::SyncCache& cache)
{
const auto snapshot = cache.snapshot();
if (snapshot.accounts.empty()) {
std::cout << "No connected accounts.\n";
return EXIT_SUCCESS;
}
for (const auto& account : snapshot.accounts) {
std::cout << " " << account.id << " " << account.display_name
<< " (" << account.provider << ")\n";
}
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;
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::LibsecretOAuthSecretStore secrets;
return disconnect_account_command(cache, secrets, argv[2]);
}
if (argc == 2 && std::string_view{argv[1]} == "list") {
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);
return list_accounts_command(cache);
}
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");
}
void print_help(std::ostream& output) void print_help(std::ostream& output)
{ {
output << output <<
@@ -60,6 +269,15 @@ void print_help(std::ostream& output)
" --no-color disable ANSI styling\n" " --no-color disable ANSI styling\n"
" -h, --help show this help\n" " -h, --help show this help\n"
" -V, --version show the version\n\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 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" "Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
@@ -232,6 +450,8 @@ int print_frame(std::span<const nocal::Event> events, bool no_color,
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false, .show_calendar_picker = false,
.calendar_index = 0, .calendar_index = 0,
.show_agenda = false, .show_agenda = false,
@@ -313,6 +533,9 @@ int export_calendar(const std::filesystem::path& source_path,
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
try { try {
if (argc >= 2 && std::string_view{argv[1]} == "account") {
return account_command(argc - 1, argv + 1);
}
const auto options = parse_options(argc, argv); const auto options = parse_options(argc, argv);
auto loaded = nocal::storage::IcsStore::load(options.calendar_path); auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
print_warnings(loaded, options.calendar_path); print_warnings(loaded, options.calendar_path);
@@ -361,6 +584,68 @@ int main(int argc, char** argv)
} }
nocal::tui::TuiApp app{ nocal::tui::TuiApp app{
loaded.events, std::move(saver), options.week_start}; loaded.events, std::move(saver), options.week_start};
// --- Sync infrastructure ---
const std::filesystem::path cache_path = default_sync_cache_path();
std::error_code cache_error;
std::filesystem::create_directories(cache_path.parent_path(), cache_error);
nocal::storage::SyncCache sync_cache(cache_path);
nocal::sync::CurlHttpTransport http_transport;
nocal::sync::LibsecretOAuthSecretStore secret_store;
const auto sync_snapshot = sync_cache.snapshot();
if (!sync_snapshot.accounts.empty()) {
app.set_connected_account_id(sync_snapshot.accounts.front().id);
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();
});
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));
}
}
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS; return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
} catch (const std::exception& error) { } catch (const std::exception& error) {
std::cerr << "nocal: " << error.what() << '\n'; std::cerr << "nocal: " << error.what() << '\n';

View File

@@ -50,11 +50,22 @@ struct PendingEvent {
std::string color; std::string color;
std::optional<ParsedTime> start; std::optional<ParsedTime> start;
std::optional<ParsedTime> end; std::optional<ParsedTime> end;
std::optional<ParsedTime> recurrence_id;
std::vector<PendingProperty> recurrence_rules; std::vector<PendingProperty> recurrence_rules;
std::vector<PendingProperty> recurrence_dates; std::vector<PendingProperty> recurrence_dates;
std::vector<PendingProperty> exception_dates; std::vector<PendingProperty> exception_dates;
}; };
// Accumulated VTIMEZONE definitions from the source calendar (populated during load).
std::vector<VTimezoneDefinition> parsed_vtimezones;
bool inside_vtimezone = false;
bool vtimezone_has_errors = false;
[[nodiscard]] bool vtimezone_defined(std::string_view tzid) noexcept {
return std::any_of(parsed_vtimezones.begin(), parsed_vtimezones.end(),
[&tzid](const VTimezoneDefinition& tz) { return tz.tzid == tzid; });
}
[[nodiscard]] std::string upper(std::string_view value) { [[nodiscard]] std::string upper(std::string_view value) {
std::string result(value); std::string result(value);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) { std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
@@ -358,6 +369,10 @@ void mark_unsafe_to_rewrite(LoadResult& result) {
} }
} }
[[nodiscard]] bool is_unknown_tzid_warning(std::string_view warning) noexcept {
return warning.find("unknown TZID=") != std::string::npos;
}
[[nodiscard]] std::vector<std::string_view> split( [[nodiscard]] std::vector<std::string_view> split(
std::string_view value, char delimiter) { std::string_view value, char delimiter) {
std::vector<std::string_view> parts; std::vector<std::string_view> parts;
@@ -476,12 +491,33 @@ template <typename Integer>
} else if (name == "BYDAY" && !has_by_weekday) { } else if (name == "BYDAY" && !has_by_weekday) {
has_by_weekday = true; has_by_weekday = true;
for (const std::string_view day_name : split(part_value, ',')) { for (const std::string_view day_name : split(part_value, ',')) {
const auto day = parse_weekday(day_name); int ordinal = 0;
std::string_view weekday_part = day_name;
// Check for ordinal prefix: optional sign, optional digits
if (!day_name.empty() && (day_name[0] == '+' || day_name[0] == '-' || (day_name[0] >= '0' && day_name[0] <= '9'))) {
std::size_t digit_end = 0;
if (day_name[0] == '+' || day_name[0] == '-') {
digit_end = 1;
}
while (digit_end < day_name.size() && day_name[digit_end] >= '0' && day_name[digit_end] <= '9') {
++digit_end;
}
if (digit_end > 0U && digit_end < day_name.size()) {
std::string number_str(day_name.substr(0, digit_end));
ordinal = std::stoi(number_str);
if (ordinal < -5 || ordinal > 5 || ordinal == 0) {
error = "RRULE BYDAY ordinal must be from -5 to -1 or 1 to 5";
return std::nullopt;
}
weekday_part = day_name.substr(digit_end);
}
}
const auto day = parse_weekday(weekday_part);
if (!day) { if (!day) {
error = "RRULE BYDAY supports only non-ordinal weekdays"; error = "RRULE BYDAY contains an invalid weekday";
return std::nullopt; return std::nullopt;
} }
rule.by_weekday.push_back(*day); rule.by_weekday.push_back({ordinal, *day});
} }
} else if (name == "BYMONTHDAY" && !has_by_month_day) { } else if (name == "BYMONTHDAY" && !has_by_month_day) {
has_by_month_day = true; has_by_month_day = true;
@@ -518,12 +554,6 @@ template <typename Integer>
error = "RRULE COUNT and UNTIL are mutually exclusive"; error = "RRULE COUNT and UNTIL are mutually exclusive";
return std::nullopt; return std::nullopt;
} }
if ((has_by_weekday && rule.frequency != RecurrenceFrequency::weekly)
|| (has_by_month_day && rule.frequency != RecurrenceFrequency::monthly)
|| (has_by_month && rule.frequency != RecurrenceFrequency::yearly)) {
error = "RRULE selector is not supported for this frequency";
return std::nullopt;
}
return rule; return rule;
} }
@@ -586,17 +616,22 @@ template <typename Integer>
} }
[[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) { [[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) {
const time_zone* zone = locate_zone(zone_name); try {
const local_seconds local = zone->to_local(floor<seconds>(value)); const time_zone* zone = locate_zone(zone_name);
const local_days date = floor<days>(local); const local_seconds local = zone->to_local(floor<seconds>(value));
const year_month_day ymd{date}; const local_days date = floor<days>(local);
const hh_mm_ss time{local - date}; const year_month_day ymd{date};
return four_digits(static_cast<int>(ymd.year())) const hh_mm_ss time{local - date};
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.month()))) return four_digits(static_cast<int>(ymd.year()))
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T" + two_digits(static_cast<int>(static_cast<unsigned>(ymd.month())))
+ two_digits(static_cast<int>(time.hours().count())) + two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T"
+ two_digits(static_cast<int>(time.minutes().count())) + two_digits(static_cast<int>(time.hours().count()))
+ two_digits(static_cast<int>(time.seconds().count())); + two_digits(static_cast<int>(time.minutes().count()))
+ two_digits(static_cast<int>(time.seconds().count()));
} catch (const std::runtime_error&) {
// Custom TZID not known to the system; fall back to local formatting.
return format_floating_time(value);
}
} }
[[nodiscard]] std::string format_event_time(const Event& event, TimePoint value) { [[nodiscard]] std::string format_event_time(const Event& event, TimePoint value) {
@@ -660,7 +695,14 @@ template <typename Range, typename Formatter>
} }
if (!rule.by_weekday.empty()) { if (!rule.by_weekday.empty()) {
result += ";BYDAY=" + join_values(rule.by_weekday, result += ";BYDAY=" + join_values(rule.by_weekday,
[](weekday day) { return weekday_name(day); }); [](const ByWeekday& bw) {
std::string out;
if (bw.ordinal != 0) {
out += (bw.ordinal > 0 ? "+" : "") + std::to_string(bw.ordinal);
}
out += weekday_name(bw.day);
return out;
});
} }
if (!rule.by_month_day.empty()) { if (!rule.by_month_day.empty()) {
result += ";BYMONTHDAY=" + join_values(rule.by_month_day, result += ";BYMONTHDAY=" + join_values(rule.by_month_day,
@@ -704,14 +746,34 @@ void write_text_property(std::ostream& output, std::string_view name, std::strin
} }
} }
void serialize_calendar(std::ostream& output, std::span<const Event> events) { void serialize_calendar(std::ostream& output, std::span<const Event> events,
std::span<const VTimezoneDefinition> vtimezones) {
write_folded(output, "BEGIN:VCALENDAR"); write_folded(output, "BEGIN:VCALENDAR");
write_folded(output, "VERSION:2.0"); write_folded(output, "VERSION:2.0");
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN"); write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
write_folded(output, "CALSCALE:GREGORIAN"); write_folded(output, "CALSCALE:GREGORIAN");
// Preserve embedded VTIMEZONE definitions for round-trip safety.
for (const auto& vt : vtimezones) {
if (vt.tzid.empty()) continue;
write_folded(output, "BEGIN:VTIMEZONE");
write_folded(output, "TZID:" + vt.tzid);
if (!vt.raw.empty()) {
std::istringstream raw_stream(vt.raw);
for (const auto& line : unfold(raw_stream)) {
write_folded(output, line);
}
}
write_folded(output, "END:VTIMEZONE");
}
for (const Event& event : events) { for (const Event& event : events) {
write_folded(output, "BEGIN:VEVENT"); write_folded(output, "BEGIN:VEVENT");
write_folded(output, "UID:" + escape_text(event.uid)); write_folded(output, "UID:" + escape_text(event.uid));
if (event.recurrence_id) {
const std::string params = event.all_day ? ";VALUE=DATE" : time_property_parameters(event);
write_folded(output, "RECURRENCE-ID" + params + ":"
+ (event.all_day ? format_date(*event.recurrence_id)
: format_event_time(event, *event.recurrence_id)));
}
if (event.all_day) { if (event.all_day) {
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start)); write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end)); write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
@@ -1038,8 +1100,8 @@ void validate_recurrence(const Event& event) {
if (rule.interval == 0) invalid("INTERVAL must be positive"); if (rule.interval == 0) invalid("INTERVAL must be positive");
if (rule.count && *rule.count == 0) invalid("COUNT must be positive"); if (rule.count && *rule.count == 0) invalid("COUNT must be positive");
if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive"); if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive");
for (weekday day : rule.by_weekday) { for (const ByWeekday& bw : rule.by_weekday) {
if (!day.ok()) invalid("BYDAY contains an invalid weekday"); if (!bw.day.ok()) invalid("BYDAY contains an invalid weekday");
} }
for (int day : rule.by_month_day) { for (int day : rule.by_month_day) {
if (day == 0 || day < -31 || day > 31) { if (day == 0 || day < -31 || day > 31) {
@@ -1051,17 +1113,18 @@ void validate_recurrence(const Event& event) {
invalid("BYMONTH contains an out-of-range month"); invalid("BYMONTH contains an out-of-range month");
} }
} }
if ((!rule.by_weekday.empty() && rule.frequency != RecurrenceFrequency::weekly)
|| (!rule.by_month_day.empty() && rule.frequency != RecurrenceFrequency::monthly)
|| (!rule.by_month.empty() && rule.frequency != RecurrenceFrequency::yearly)) {
invalid("selector does not apply to the selected frequency");
} }
}
void validate_event_for_save(const Event& event) { void validate_event_for_save(
const Event& event, std::span<const VTimezoneDefinition> vtimezones) {
if (event.uid.empty()) { if (event.uid.empty()) {
throw std::invalid_argument("cannot save an event with an empty UID"); throw std::invalid_argument("cannot save an event with an empty UID");
} }
if (event.recurrence_id && event.recurrence) {
throw std::invalid_argument(
"cannot save override event '" + event.uid
+ "' with both RECURRENCE-ID and RRULE");
}
if (event.end < event.start || (event.all_day && event.end == event.start)) { if (event.end < event.start || (event.all_day && event.end == event.start)) {
throw std::invalid_argument( throw std::invalid_argument(
"cannot save event '" + event.uid + "' with an invalid interval"); "cannot save event '" + event.uid + "' with an invalid interval");
@@ -1103,9 +1166,13 @@ void validate_event_for_save(const Event& event) {
try { try {
(void)locate_zone(event.time_zone); (void)locate_zone(event.time_zone);
} catch (const std::runtime_error&) { } catch (const std::runtime_error&) {
throw std::invalid_argument( bool defined_by_vtimezone = std::any_of(vtimezones.begin(), vtimezones.end(),
"cannot save zoned event '" + event.uid + "' with unknown TZID '" [&event](const VTimezoneDefinition& tz) { return tz.tzid == event.time_zone; });
+ event.time_zone + "'"); if (!defined_by_vtimezone) {
throw std::invalid_argument(
"cannot save zoned event '" + event.uid + "' with unknown TZID '"
+ event.time_zone + "'");
}
} }
} else if (!event.time_zone.empty()) { } else if (!event.time_zone.empty()) {
throw std::invalid_argument( throw std::invalid_argument(
@@ -1135,6 +1202,10 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} }
result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes); result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes);
parsed_vtimezones.clear();
inside_vtimezone = false;
vtimezone_has_errors = false;
std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary); std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
const auto lines = unfold(input); const auto lines = unfold(input);
std::optional<PendingEvent> pending; std::optional<PendingEvent> pending;
@@ -1219,6 +1290,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
event.description = std::move(pending->description); event.description = std::move(pending->description);
event.calendar_id = std::move(pending->calendar_id); event.calendar_id = std::move(pending->calendar_id);
event.color = std::move(pending->color); event.color = std::move(pending->color);
if (pending->recurrence_id) {
event.recurrence_id = pending->recurrence_id->value;
}
if (pending->recurrence_rules.size() > 1) { if (pending->recurrence_rules.size() > 1) {
add_warning(result, pending->recurrence_rules[1].line, add_warning(result, pending->recurrence_rules[1].line,
@@ -1252,8 +1326,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue; continue;
} }
if (!warning.empty()) { if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, property.line, std::move(warning)); add_warning(result, property.line, std::move(warning));
mark_unsafe_to_rewrite(result); if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
} }
if (std::find(event.recurrence_additions.begin(), if (std::find(event.recurrence_additions.begin(),
event.recurrence_additions.end(), parsed->value) event.recurrence_additions.end(), parsed->value)
@@ -1275,8 +1352,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue; continue;
} }
if (!warning.empty()) { if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, property.line, std::move(warning)); add_warning(result, property.line, std::move(warning));
mark_unsafe_to_rewrite(result); if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
} }
if (std::find(event.recurrence_exceptions.begin(), if (std::find(event.recurrence_exceptions.begin(),
event.recurrence_exceptions.end(), parsed->value) event.recurrence_exceptions.end(), parsed->value)
@@ -1298,6 +1378,39 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} }
if (!pending) { if (!pending) {
// Handle embedded VTIMEZONE for safety analysis and round-trip.
if (name == "BEGIN" && upper(value) == "VTIMEZONE") {
if (inside_vtimezone) {
add_warning(result, index + 1, "nested VTIMEZONE is not supported");
mark_unsafe_to_rewrite(result);
} else {
inside_vtimezone = true;
vtimezone_has_errors = false;
parsed_vtimezones.emplace_back();
}
continue;
}
if (name == "END" && upper(value) == "VTIMEZONE") {
if (!inside_vtimezone) {
add_warning(result, index + 1, "END:VTIMEZONE without BEGIN:VTIMEZONE");
mark_unsafe_to_rewrite(result);
} else {
inside_vtimezone = false;
}
continue;
}
if (inside_vtimezone) {
auto& vt = parsed_vtimezones.back();
if (!vt.raw.empty()) vt.raw += "\r\n";
vt.raw += std::string(line);
if (name == "TZID" && vt.tzid.empty()) {
vt.tzid = std::string(value);
}
// Sub-components and their properties are tracked but not deeply
// parsed; unsupported content inside VTIMEZONE is deferred.
continue;
}
const std::string normalized_value = upper(value); const std::string normalized_value = upper(value);
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR") const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|| (name == "END" && normalized_value == "VCALENDAR"); || (name == "END" && normalized_value == "VCALENDAR");
@@ -1316,7 +1429,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue; continue;
} }
const bool time_property = name == "DTSTART" || name == "DTEND" const bool time_property = name == "DTSTART" || name == "DTEND"
|| name == "RDATE" || name == "EXDATE"; || name == "RDATE" || name == "EXDATE" || name == "RECURRENCE-ID";
const bool recurrence_property = name == "RRULE"; const bool recurrence_property = name == "RRULE";
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION" const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID" || name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
@@ -1366,11 +1479,34 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
mark_unsafe_to_rewrite(result); mark_unsafe_to_rewrite(result);
} else { } else {
if (!warning.empty()) { if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, index + 1, std::move(warning)); add_warning(result, index + 1, std::move(warning));
mark_unsafe_to_rewrite(result); if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
} }
destination = *parsed; destination = *parsed;
} }
} else if (name == "RECURRENCE-ID") {
if (pending->recurrence_id) {
add_warning(result, index + 1, "duplicate RECURRENCE-ID property");
mark_unsafe_to_rewrite(result);
}
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()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, index + 1, std::move(warning));
if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
}
pending->recurrence_id = *parsed;
}
} }
} }
@@ -1378,6 +1514,59 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
add_warning(result, event_start_line, "unterminated VEVENT was skipped"); add_warning(result, event_start_line, "unterminated VEVENT was skipped");
mark_unsafe_to_rewrite(result); mark_unsafe_to_rewrite(result);
} }
if (inside_vtimezone) {
add_warning(result, 0, "unterminated VTIMEZONE; timezone rules may be incomplete");
mark_unsafe_to_rewrite(result);
}
// VTIMEZONE-defined TZIDs that are unknown to the system are safe to edit
// (we can round-trip the definition) but DST handling is approximate.
for (const auto& event : result.events) {
if (event.time_basis == TimeBasis::zoned && !event.time_zone.empty()) {
const std::string& tzid = event.time_zone;
try {
(void)locate_zone(tzid);
} catch (const std::runtime_error&) {
if (vtimezone_defined(tzid)) {
add_warning(result, 0,
"TZID '" + tzid + "' is defined by VTIMEZONE but not by the system; "
"DST transitions use local time as an approximation");
}
}
}
}
// Remove "unknown TZID" warnings only for TZIDs defined in VTIMEZONE.
auto safe_end = std::remove_if(result.warnings.begin(), result.warnings.end(),
[](const std::string& w) {
const auto marker = w.find("unknown TZID=");
if (marker == std::string::npos) return false;
// Extract TZID from warning like "line N: unknown TZID='Custom/Test'; ..."
const std::size_t start = w.find('\'', marker);
const std::size_t end = w.find('\'', start + 1);
if (start == std::string::npos || end == std::string::npos) return false;
const std::string tzid = w.substr(start + 1, end - start - 1);
return vtimezone_defined(tzid);
});
result.warnings.erase(safe_end, result.warnings.end());
// If no undefined-TZID warnings remain and only VTIMEZONE-DST warnings
// (safe to edit) are present, mark the calendar as safe to rewrite.
bool has_undefined_tzid = std::any_of(result.warnings.begin(), result.warnings.end(),
[](const std::string& w) { return w.find("unknown TZID=") != std::string::npos; });
if (has_undefined_tzid) {
mark_unsafe_to_rewrite(result);
} else if (!result.warnings.empty()) {
bool has_unsafe_warning = std::any_of(result.warnings.begin(), result.warnings.end(),
[](const std::string& w) {
return w.find("VTIMEZONE") == std::string::npos || w.find("DST") == std::string::npos;
});
if (!has_unsafe_warning) {
result.safe_to_rewrite = true;
}
}
result.vtimezones = std::move(parsed_vtimezones);
return result; return result;
} }
@@ -1389,16 +1578,17 @@ std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
FileRevision IcsStore::save( FileRevision IcsStore::save(
const std::filesystem::path& path, std::span<const Event> events, const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected) { std::optional<FileRevision> expected,
std::span<const VTimezoneDefinition> vtimezones) {
if (path.empty() || path.filename().empty()) { if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file"); throw std::invalid_argument("calendar path must name a file");
} }
for (const Event& event : events) { for (const Event& event : events) {
validate_event_for_save(event); validate_event_for_save(event, vtimezones);
} }
std::ostringstream serialized(std::ios::out | std::ios::binary); std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events); serialize_calendar(serialized, events, vtimezones);
if (!serialized) { if (!serialized) {
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'"); throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
} }

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

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

@@ -0,0 +1,437 @@
#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)
| (remaining == 2 ? (static_cast<std::uint32_t>(
static_cast<unsigned char>(input[i + 1]))
<< 8)
: 0);
output.push_back(table[(octet >> 18) & 0x3f]);
output.push_back(table[(octet >> 12) & 0x3f]);
if (remaining == 2) {
output.push_back(table[(octet >> 6) & 0x3f]);
}
output.push_back('=');
if (remaining == 1) {
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()},
{"Overwrite", "F"},
};
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()},
{"Overwrite", "F"},
};
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 (status 401) from " + url
+ "\nCheck that --server points to the CalDAV root (e.g. "
"https://cloud.example.com/remote.php/dav) and that the username/password are correct.");
}
if (response.status == 403) {
throw std::runtime_error("CalDAV: forbidden from " + url);
}
if (response.status == 404) {
throw std::runtime_error("CalDAV: resource not found from " + 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,132 @@
#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,
OAuthSecretStore& secret_store,
CalDAVServerConfig config)
: transport_(transport),
cache_(cache),
secret_store_(secret_store),
config_(std::move(config)) {}
ConnectedAccount CalDAVSyncProvider::connect_account() {
// Credentials are loaded from Secret Service.
const auto credential = secret_store_.load_caldav_credential(config_.account_id);
if (!credential) {
throw std::runtime_error("CalDAV: no credentials stored; run `nocal account connect` "
"first");
}
CalDAVAccountConfig account_config;
account_config.server_url = config_.server_url;
account_config.account_id = config_.account_id;
account_config.username = credential->username;
account_config.password = credential->password;
CalDAVSync sync(transport_, cache_, account_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});
const auto credential = secret_store_.load_caldav_credential(config_.account_id);
if (!credential) {
throw std::runtime_error("CalDAV: no credentials stored; account may be disconnected");
}
CalDAVAccountConfig account_config;
account_config.server_url = config_.server_url;
account_config.account_id = config_.account_id;
account_config.username = credential->username;
account_config.password = credential->password;
CalDAVSync sync(transport_, cache_, account_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");
}
secret_store_.erase_caldav_credential(config_.account_id);
}
} // namespace nocal::sync

262
src/sync/caldav_xml.cpp Normal file
View File

@@ -0,0 +1,262 @@
#include "nocal/sync/caldav_xml.hpp"
#include <algorithm>
#include <cctype>
#include <stdexcept>
#include <string>
#include <string_view>
namespace nocal::sync {
namespace {
constexpr std::size_t max_depth = 64;
std::string_view trim(std::string_view s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.remove_prefix(1);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.remove_suffix(1);
return s;
}
std::string replace_entities(std::string_view s) {
std::string result;
result.reserve(s.size());
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == '&' && i + 4 < s.size() && s[i + 1] == 'l' && s[i + 2] == 't' && s[i + 3] == ';') {
result.push_back('<');
i += 3;
} else if (s[i] == '&' && i + 4 < s.size() && s[i + 1] == 'g' && s[i + 2] == 't' && s[i + 3] == ';') {
result.push_back('>');
i += 3;
} else if (s[i] == '&' && i + 5 < s.size() && s[i + 1] == 'a' && s[i + 2] == 'm'
&& s[i + 3] == 'p' && s[i + 4] == ';') {
result.push_back('&');
i += 4;
} else if (s[i] == '&' && i + 6 < s.size() && s[i + 1] == 'q' && s[i + 2] == 'u'
&& s[i + 3] == 'o' && s[i + 4] == 't' && s[i + 5] == ';') {
result.push_back('"');
i += 5;
} else if (s[i] == '&' && i + 6 < s.size() && s[i + 1] == 'a' && s[i + 2] == 'p'
&& s[i + 3] == 'o' && s[i + 4] == 's' && s[i + 5] == ';') {
result.push_back('\'');
i += 5;
} else {
result.push_back(s[i]);
}
}
return result;
}
class XmlParser {
public:
explicit XmlParser(std::string_view doc) : doc_(doc), pos_(0) {}
[[nodiscard]] XmlElement parse_root() {
skip_whitespace();
while (pos_ < doc_.size()) {
skip_whitespace();
if (pos_ >= doc_.size()) break;
if (doc_[pos_] == '<') {
if (pos_ + 1 < doc_.size() && doc_[pos_ + 1] == '?') {
skip_processing_instruction();
} else if (pos_ + 4 < doc_.size() && doc_.substr(pos_, 4) == "<!--") {
skip_comment();
} else {
elements_.push_back(parse_element(1));
}
} else {
++pos_;
}
}
if (elements_.empty()) {
throw std::runtime_error("XML document contains no root element");
}
return std::move(elements_[0]);
}
private:
std::string_view doc_;
std::size_t pos_;
std::vector<XmlElement> elements_;
void skip_whitespace() {
while (pos_ < doc_.size() && std::isspace(static_cast<unsigned char>(doc_[pos_]))) {
++pos_;
}
}
void skip_processing_instruction() {
if (pos_ + 2 <= doc_.size() && doc_[pos_] == '<' && doc_[pos_ + 1] == '?') {
pos_ += 2;
while (pos_ + 1 < doc_.size() && !(doc_[pos_] == '?' && doc_[pos_ + 1] == '>')) {
++pos_;
}
if (pos_ + 1 < doc_.size()) pos_ += 2;
}
}
void skip_comment() {
if (pos_ + 4 <= doc_.size() && doc_.substr(pos_, 4) == "<!--") {
pos_ += 4;
while (pos_ + 2 < doc_.size() && !(doc_[pos_] == '-' && doc_[pos_ + 1] == '-'
&& doc_[pos_ + 2] == '>')) {
++pos_;
}
if (pos_ + 2 < doc_.size()) pos_ += 3;
}
}
std::string read_tag_name() {
std::string tag;
while (pos_ < doc_.size() && !std::isspace(static_cast<unsigned char>(doc_[pos_]))
&& doc_[pos_] != '>' && doc_[pos_] != '/' && doc_[pos_] != '<') {
tag.push_back(doc_[pos_++]);
}
if (tag.empty()) {
throw std::runtime_error("XML: empty tag name at position " + std::to_string(pos_));
}
return tag;
}
std::vector<std::pair<std::string, std::string>> read_attributes() {
std::vector<std::pair<std::string, std::string>> attrs;
skip_whitespace();
while (pos_ < doc_.size() && doc_[pos_] != '>' && doc_[pos_] != '/') {
std::string name;
while (pos_ < doc_.size() && !std::isspace(static_cast<unsigned char>(doc_[pos_]))
&& doc_[pos_] != '=') {
name.push_back(doc_[pos_++]);
}
skip_whitespace();
if (pos_ >= doc_.size() || doc_[pos_] != '=') {
throw std::runtime_error("XML: expected '=' after attribute name at position "
+ std::to_string(pos_));
}
++pos_;
skip_whitespace();
if (pos_ >= doc_.size() || (doc_[pos_] != '"' && doc_[pos_] != '\'')) {
throw std::runtime_error("XML: expected quoted attribute value at position "
+ std::to_string(pos_));
}
const char quote = doc_[pos_++];
std::string value;
while (pos_ < doc_.size() && doc_[pos_] != quote) {
value.push_back(doc_[pos_++]);
}
if (pos_ >= doc_.size()) {
throw std::runtime_error("XML: unterminated attribute value at position "
+ std::to_string(pos_));
}
++pos_;
attrs.emplace_back(std::move(name), std::move(value));
skip_whitespace();
}
return attrs;
}
XmlElement parse_element(std::size_t depth) {
if (depth > max_depth) {
throw std::runtime_error("XML: maximum nesting depth exceeded");
}
if (pos_ >= doc_.size() || doc_[pos_] != '<') {
throw std::runtime_error("XML: expected '<' at position " + std::to_string(pos_));
}
++pos_;
const std::string tag = read_tag_name();
const auto attributes = read_attributes();
skip_whitespace();
XmlElement elem;
elem.tag = tag;
elem.attributes = std::move(attributes);
if (pos_ < doc_.size() && doc_[pos_] == '/') {
++pos_;
if (pos_ < doc_.size() && doc_[pos_] == '>') {
++pos_;
}
return elem;
}
if (pos_ >= doc_.size() || doc_[pos_] != '>') {
throw std::runtime_error("XML: expected '>' at position " + std::to_string(pos_));
}
++pos_;
while (pos_ < doc_.size()) {
if (pos_ + 9 <= doc_.size() && doc_.substr(pos_, 9) == "<![CDATA[") {
pos_ += 9;
std::size_t start = pos_;
while (pos_ + 2 < doc_.size() && !(doc_[pos_] == ']' && doc_[pos_ + 1] == ']'
&& doc_[pos_ + 2] == '>')) {
++pos_;
}
elem.cdata.append(doc_.data() + start, pos_ - start);
if (pos_ + 2 < doc_.size()) pos_ += 3;
} else if (pos_ < doc_.size() && doc_[pos_] == '<' && pos_ + 1 < doc_.size()
&& doc_[pos_ + 1] == '/') {
break;
} else if (pos_ < doc_.size() && doc_[pos_] == '<') {
elem.children.push_back(parse_element(depth + 1));
} else {
std::size_t start = pos_;
while (pos_ < doc_.size() && doc_[pos_] != '<') {
++pos_;
}
elem.text.append(doc_.data() + start, pos_ - start);
}
}
elem.text = replace_entities(trim(elem.text));
if (pos_ < doc_.size() && doc_[pos_] == '<' && pos_ + 1 < doc_.size()
&& doc_[pos_ + 1] == '/') {
pos_ += 2;
std::string close_tag;
while (pos_ < doc_.size() && doc_[pos_] != '>') {
close_tag.push_back(doc_[pos_++]);
}
close_tag = trim(close_tag);
if (close_tag != tag) {
throw std::runtime_error("XML: mismatched tags '" + tag + "' and '" + close_tag + "'");
}
if (pos_ < doc_.size()) ++pos_;
} else {
throw std::runtime_error("XML: unclosed element '" + tag + "'");
}
return elem;
}
};
} // namespace
XmlElement parse_xml(const std::string_view document, const std::size_t max_size) {
if (document.size() > max_size) {
throw std::runtime_error("XML document exceeds maximum size");
}
XmlParser parser(document);
return parser.parse_root();
}
std::vector<const XmlElement*> find_children(const XmlElement& parent, const std::string_view tag) {
std::vector<const XmlElement*> result;
for (const auto& child : parent.children) {
if (child.tag == tag) {
result.push_back(&child);
}
}
return result;
}
std::string_view get_child_text(const XmlElement& parent, const std::string_view tag) {
for (const auto& child : parent.children) {
if (child.tag == tag) {
return child.cdata.empty() ? child.text : child.cdata;
}
}
return {};
}
std::string_view get_attribute(const XmlElement& elem, const std::string_view name) {
for (const auto& [n, v] : elem.attributes) {
if (n == name) {
return v;
}
}
return {};
}
} // namespace nocal::sync

View File

@@ -329,6 +329,8 @@ void set_option(CURL* handle, CURLoption option, Value value) {
case HttpMethod::put: return "PUT"; case HttpMethod::put: return "PUT";
case HttpMethod::patch: return "PATCH"; case HttpMethod::patch: return "PATCH";
case HttpMethod::delete_: return "DELETE"; case HttpMethod::delete_: return "DELETE";
case HttpMethod::propfind: return "PROPFIND";
case HttpMethod::report: return "REPORT";
} }
throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request method"); throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request method");
} }

View File

@@ -0,0 +1,117 @@
#include "nocal/sync/microsoft_account.hpp"
#include "nocal/sync/microsoft_graph.hpp"
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/secret_store.hpp"
#include <openssl/evp.h>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <functional>
#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 unsigned char byte : digest) {
output.push_back(hexadecimal[byte >> 4U]);
output.push_back(hexadecimal[byte & 0x0fU]);
}
return output;
}
} // namespace
std::string default_microsoft_client_id() {
const char* value = std::getenv("NOCAL_MICROSOFT_CLIENT_ID");
if (value != nullptr && value[0] != '\0') {
return std::string(value);
}
return std::string(microsoft_client_id_placeholder);
}
OAuthClientConfig microsoft_oauth_config(std::string client_id) {
OAuthClientConfig config;
config.client_id = std::move(client_id);
config.authorization_endpoint =
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
config.token_endpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
return config;
}
std::string microsoft_account_id(std::string_view remote_subject) {
std::string input("account");
input.push_back('\0');
input += "microsoft-graph";
input.push_back('\0');
input += remote_subject;
return std::string("graph-account-") + sha256_hex(input);
}
storage::CachedAccount connect_microsoft_account(const OAuthClientConfig& config,
BrowserLauncher& browser, AuthorizationCallbackReceiver& receiver,
HttpTransport& transport, EntropySource& entropy, OAuthSecretStore& secret_store,
storage::SyncCache& cache, const std::function<std::int64_t()>& clock) {
// Step 1: authorization-code flow with PKCE S256
HttpResponse response;
try {
response = authorize_with_pkce(config, browser, receiver, transport, entropy);
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft authorization failed");
}
// Step 2: strict token parsing (requires initial refresh token)
OAuthTokens tokens;
try {
tokens = parse_oauth_token_response(response.body, clock());
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft token response invalid");
}
// Step 3: one /me identity read
MicrosoftGraphSync graph{transport, cache};
MicrosoftGraphIdentity identity;
try {
identity = graph.fetch_identity(tokens.access_token);
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft identity read failed");
}
// Step 4: upsert account into the sync cache
const std::string account_id = microsoft_account_id(identity.remote_subject);
const storage::CachedAccount account{
account_id, "microsoft-graph", identity.remote_subject, identity.display_name};
try {
cache.upsert_account(account);
} catch (const std::exception&) {
throw MicrosoftAccountError("sync cache update failed");
}
// Step 5: persist credentials to the secret store
// On failure the cached account row remains (documented disconnected state).
try {
secret_store.store(account_id, tokens);
} catch (const std::exception&) {
throw MicrosoftAccountError("credential storage failed");
}
return account;
}
} // namespace nocal::sync

View File

@@ -533,19 +533,32 @@ struct DeltaContinuation {
MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache) MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
: transport_(transport), cache_(cache) {} : transport_(transport), cache_(cache) {}
MicrosoftGraphIdentity MicrosoftGraphSync::fetch_identity(
std::string_view access_token) const {
try {
const MicrosoftGraphCredentials credentials{"identity", std::string(access_token)};
validate_credentials(credentials);
const nlohmann::json object = parse_json_object(
send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
MicrosoftGraphIdentity identity{required_string(object, "id"),
optional_string(object, "displayName")};
if (identity.display_name.empty()) {
identity.display_name = required_string(object, "userPrincipalName");
}
return identity;
} catch (...) {
throw MicrosoftGraphError("Microsoft Graph identity read failed");
}
}
CachedAccount MicrosoftGraphSync::refresh_account( CachedAccount MicrosoftGraphSync::refresh_account(
const MicrosoftGraphCredentials& credentials) { const MicrosoftGraphCredentials& credentials) {
try { try {
validate_credentials(credentials); validate_credentials(credentials);
const nlohmann::json object = const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token);
parse_json_object(send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
const std::string remote_subject = required_string(object, "id");
std::string display_name = optional_string(object, "displayName");
if (display_name.empty()) {
display_name = required_string(object, "userPrincipalName");
}
CachedAccount account{ CachedAccount account{
credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)}; credentials.account_id, "microsoft-graph", identity.remote_subject,
identity.display_name};
cache_.upsert_account(account); cache_.upsert_account(account);
return account; return account;
} catch (...) { } catch (...) {

View File

@@ -0,0 +1,231 @@
#include "nocal/sync/microsoft_sync_provider.hpp"
#include "nocal/sync/microsoft_account.hpp"
#include "nocal/sync/microsoft_graph.hpp"
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/secret_store.hpp"
#include "nocal/sync/token_broker.hpp"
#include "nocal/storage/sync_cache.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace nocal::sync {
namespace {
class NoopObserver final : public SyncObserver {
public:
void on_sync_progress(const SyncProgressEvent&) override {}
};
void send_stage(SyncObserver& observer, SyncStage stage) {
SyncProgressEvent event;
event.stage = stage;
observer.on_sync_progress(event);
}
[[nodiscard]] bool calendar_is_primary(const storage::CachedCalendar& calendar) {
try {
const nlohmann::json object = nlohmann::json::parse(calendar.raw_payload);
const auto found = object.find("isDefaultCalendar");
return found != object.end() && found->is_boolean() && found->get<bool>();
} catch (const nlohmann::json::exception&) {
return false;
}
}
[[nodiscard]] MicrosoftGraphWindow default_window(std::int64_t now_seconds) {
const std::int64_t now_micros = now_seconds * 1'000'000;
constexpr std::int64_t six_months_micros =
180LL * 24LL * 60LL * 60LL * 1'000'000LL;
constexpr std::int64_t twelve_months_micros =
365LL * 24LL * 60LL * 60LL * 1'000'000LL;
MicrosoftGraphWindow window;
window.start_epoch_microseconds = now_micros - six_months_micros;
window.end_epoch_microseconds = now_micros + twelve_months_micros;
return window;
}
} // namespace
MicrosoftAccountSyncProvider::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)
: token_broker_(token_broker),
secret_store_(secret_store),
cache_(cache),
transport_(transport),
oauth_config_(oauth_config),
clock_(std::move(clock)),
browser_(browser),
callback_receiver_(callback_receiver),
entropy_(entropy),
graph_(std::make_unique<MicrosoftGraphSync>(transport_, cache_)) {}
MicrosoftAccountSyncProvider::~MicrosoftAccountSyncProvider() = default;
std::string_view MicrosoftAccountSyncProvider::provider_id() const noexcept {
return "microsoft-graph";
}
ConnectedAccount MicrosoftAccountSyncProvider::connect_account() {
NoopObserver noop;
return do_connect(noop);
}
ConnectedAccount MicrosoftAccountSyncProvider::do_connect(SyncObserver& observer) {
// Step 1: Send authenticating stage
send_stage(observer, SyncStage::authenticating);
// Step 2: Run PKCE flow → parse tokens
HttpResponse response;
try {
response = authorize_with_pkce(
oauth_config_, browser_, callback_receiver_, transport_, entropy_);
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft authorization failed");
}
OAuthTokens tokens;
try {
tokens = parse_oauth_token_response(response.body, clock_());
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft token response invalid");
}
// Step 3: Fetch /me identity
send_stage(observer, SyncStage::refreshing_identity);
MicrosoftGraphIdentity identity;
try {
identity = graph_->fetch_identity(tokens.access_token);
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft identity read failed");
}
// Step 4: Upsert account into cache
const std::string account_id = microsoft_account_id(identity.remote_subject);
const storage::CachedAccount account{
account_id, "microsoft-graph", identity.remote_subject, identity.display_name};
try {
cache_.upsert_account(account);
} catch (const std::exception&) {
throw MicrosoftAccountError("sync cache update failed");
}
// Step 5: Persist credentials (failure leaves a disconnected account row)
try {
secret_store_.store(account_id, tokens);
} catch (const std::exception&) {
throw MicrosoftAccountError("credential storage failed");
}
// Step 6: Report completion
send_stage(observer, SyncStage::completed);
return ConnectedAccount{account_id, identity.display_name};
}
OAuthTokens MicrosoftAccountSyncProvider::get_tokens(std::string_view account_id) {
// Delegate to the token broker which handles refresh, expiry, and
// AccountDisconnected on 400/401.
return token_broker_.valid_tokens(account_id);
}
void MicrosoftAccountSyncProvider::sync_account(
std::string_view account_id, SyncObserver& observer) {
// Step 1: Authenticating — get valid tokens or throw AccountDisconnected
send_stage(observer, SyncStage::authenticating);
OAuthTokens tokens = get_tokens(account_id);
MicrosoftGraphCredentials credentials{std::string(account_id),
std::move(tokens.access_token)};
// Step 2: Refresh identity
send_stage(observer, SyncStage::refreshing_identity);
graph_->refresh_account(credentials);
// Step 3: Discover calendars
send_stage(observer, SyncStage::discovering_calendars);
graph_->refresh_calendars(credentials);
// Step 4: Get cached calendars and sync each active one
const storage::CacheSnapshot snapshot = cache_.snapshot();
std::vector<const storage::CachedCalendar*> active_calendars;
for (const auto& cal : snapshot.calendars) {
if (cal.account_id == account_id && cal.active) {
active_calendars.push_back(&cal);
}
}
const std::size_t total = active_calendars.size();
std::size_t completed = 0;
for (const storage::CachedCalendar* cal : active_calendars) {
const bool is_primary = calendar_is_primary(*cal);
SyncProgressEvent progress;
progress.stage = SyncStage::syncing_calendar;
progress.account_id = std::string(account_id);
progress.calendar_name = cal->name;
progress.calendars_completed = completed;
progress.calendars_total = total;
observer.on_sync_progress(progress);
try {
sync_one_calendar(account_id, cal->id, cal->name, is_primary, observer);
} catch (const std::exception&) {
// Continue past individual calendar failures; report via the
// completed count but the error is not surfaced through the
// observer (SyncObserver has no error flag).
}
++completed;
}
// Step 5: Report completion
send_stage(observer, SyncStage::completed);
}
void MicrosoftAccountSyncProvider::sync_one_calendar(
std::string_view account_id,
std::string_view calendar_id,
std::string_view /*calendar_name*/,
bool is_primary,
SyncObserver& /*observer*/) {
OAuthTokens tokens = get_tokens(account_id);
MicrosoftGraphCredentials credentials{std::string(account_id),
std::move(tokens.access_token)};
const MicrosoftGraphWindow window = default_window(clock_());
if (is_primary) {
graph_->sync_primary_calendar_window(credentials, window);
} else {
graph_->sync_secondary_calendar_window(
credentials, std::string(calendar_id), window);
}
}
void MicrosoftAccountSyncProvider::disconnect_account(
std::string_view account_id) {
// Erase credentials from the secret store.
// Cached provider data (account row, calendars, events) is left in place
// per SyncProvider contract (deterministic IDs make re-add idempotent).
secret_store_.erase(std::string(account_id));
}
} // namespace nocal::sync

View File

@@ -40,6 +40,23 @@ const SecretSchema oauth_schema = {
nullptr, nullptr,
}; };
const SecretSchema caldav_schema = {
"dev.nomarchy.nocal.caldav",
SECRET_SCHEMA_NONE,
{
{"account", SECRET_SCHEMA_ATTRIBUTE_STRING},
{nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING},
},
0,
nullptr,
nullptr,
nullptr,
nullptr,
nullptr,
nullptr,
nullptr,
};
[[nodiscard]] bool contains_nul_or_control(std::string_view value) { [[nodiscard]] bool contains_nul_or_control(std::string_view value) {
return std::ranges::any_of(value, [](unsigned char character) { return std::ranges::any_of(value, [](unsigned char character) {
return character == 0U || character < 0x20U || character == 0x7fU; return character == 0U || character < 0x20U || character == 0x7fU;
@@ -78,6 +95,17 @@ void validate_tokens(const OAuthTokens& tokens) {
} }
} }
void validate_caldav_credential(const CalDAVCredential& credential) {
if (credential.username.empty() || credential.password.empty()
|| contains_nul_or_control(credential.username)
|| contains_nul_or_control(credential.password)
|| credential.username.size() > maximum_secret_size
|| credential.password.size() > maximum_secret_size) {
throw SecretStoreError(
SecretStoreError::Kind::invalid_input, "invalid CalDAV credential set");
}
}
class DuplicateKey final : public std::exception {}; class DuplicateKey final : public std::exception {};
[[nodiscard]] nlohmann::json parse_unique_json(std::string_view serialized) { [[nodiscard]] nlohmann::json parse_unique_json(std::string_view serialized) {
@@ -294,6 +322,53 @@ void reject_pre_cancelled(std::stop_token stop) {
} }
} }
[[nodiscard]] SensitiveString serialize_caldav_credential(
const CalDAVCredential& credential) {
try {
nlohmann::json document = {
{"version", 1},
{"username", credential.username},
{"password", credential.password},
};
std::string serialized = document.dump();
if (serialized.size() > maximum_secret_size) {
throw SecretStoreError(
SecretStoreError::Kind::invalid_input, "invalid CalDAV credential set");
}
return SensitiveString{std::move(serialized)};
} catch (const SecretStoreError&) {
throw;
} catch (...) {
throw SecretStoreError(
SecretStoreError::Kind::invalid_input, "invalid CalDAV credential set");
}
}
[[nodiscard]] CalDAVCredential parse_caldav_credential(std::string_view serialized) {
if (serialized.empty() || serialized.size() > maximum_secret_size) {
throw SecretStoreError(
SecretStoreError::Kind::corrupt_secret, "stored CalDAV credential is invalid");
}
try {
const nlohmann::json document = parse_unique_json(serialized);
if (!document.is_object() || document.size() != 3 || !document.contains("version")
|| !document["version"].is_number_integer()
|| document["version"].get<int>() != 1
|| !document.contains("username") || !document["username"].is_string()
|| !document.contains("password") || !document["password"].is_string()) {
throw std::runtime_error("invalid document shape");
}
CalDAVCredential credential;
credential.username = document["username"].get<std::string>();
credential.password = document["password"].get<std::string>();
validate_caldav_credential(credential);
return credential;
} catch (...) {
throw SecretStoreError(
SecretStoreError::Kind::corrupt_secret, "stored CalDAV credential is invalid");
}
}
} // namespace } // namespace
SecretStoreError::SecretStoreError(Kind kind, std::string message) SecretStoreError::SecretStoreError(Kind kind, std::string message)
@@ -347,4 +422,51 @@ void LibsecretOAuthSecretStore::erase(std::string account_id, std::stop_token st
} }
} }
void LibsecretOAuthSecretStore::store_caldav_credential(
std::string account_id, const CalDAVCredential& credential, std::stop_token stop) {
validate_account(account_id);
validate_caldav_credential(credential);
reject_pre_cancelled(stop);
SensitiveString serialized = serialize_caldav_credential(credential);
Cancellation cancellation{stop};
ErrorHolder error;
const gboolean stored = secret_password_store_sync(&caldav_schema, SECRET_COLLECTION_DEFAULT,
account_id.c_str(), serialized.c_str(), cancellation.get(), error.output(), "account",
account_id.c_str(), nullptr);
if (!stored) {
throw_operation_error(error.get(), stop);
}
}
std::optional<CalDAVCredential> LibsecretOAuthSecretStore::load_caldav_credential(
std::string account_id, std::stop_token stop) {
validate_account(account_id);
reject_pre_cancelled(stop);
Cancellation cancellation{stop};
ErrorHolder error;
ReturnedSecret secret{secret_password_lookup_sync(
&caldav_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr)};
if (secret.get() == nullptr) {
if (error.get() != nullptr) {
throw_operation_error(error.get(), stop);
}
return std::nullopt;
}
const std::size_t size = std::char_traits<char>::length(secret.get());
return parse_caldav_credential(std::string_view{secret.get(), size});
}
void LibsecretOAuthSecretStore::erase_caldav_credential(std::string account_id,
std::stop_token stop) {
validate_account(account_id);
reject_pre_cancelled(stop);
Cancellation cancellation{stop};
ErrorHolder error;
static_cast<void>(secret_password_clear_sync(
&caldav_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr));
if (error.get() != nullptr) {
throw_operation_error(error.get(), stop);
}
}
} // namespace nocal::sync } // namespace nocal::sync

147
src/sync/token_broker.cpp Normal file
View File

@@ -0,0 +1,147 @@
#include "nocal/sync/token_broker.hpp"
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/secret_store.hpp"
#include <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace nocal::sync {
namespace {
[[nodiscard]] std::string percent_encode(std::string_view value) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string result;
result.reserve(value.size());
for (const unsigned char character : value) {
const bool ascii_alphanumeric = (character >= 'A' && character <= 'Z')
|| (character >= 'a' && character <= 'z')
|| (character >= '0' && character <= '9');
if (ascii_alphanumeric || character == '-' || character == '.' || character == '_'
|| character == '~') {
result.push_back(static_cast<char>(character));
} else {
result.push_back('%');
result.push_back(hex[character >> 4U]);
result.push_back(hex[character & 0x0fU]);
}
}
return result;
}
[[nodiscard]] std::string join_scopes(const std::vector<std::string>& scopes) {
std::string result;
for (const std::string& scope : scopes) {
if (!result.empty()) {
result.push_back(' ');
}
result += scope;
}
return result;
}
void append_parameter(
std::string& output, bool& first, std::string_view name, std::string_view value) {
if (!first) {
output.push_back('&');
}
first = false;
output += name;
output.push_back('=');
output += percent_encode(value);
}
[[nodiscard]] std::string make_refresh_body(const OAuthClientConfig& config,
std::string_view refresh_token, const std::string& scope) {
std::string body;
bool first = true;
append_parameter(body, first, "grant_type", "refresh_token");
append_parameter(body, first, "client_id", config.client_id);
append_parameter(body, first, "refresh_token", refresh_token);
if (!scope.empty()) {
append_parameter(body, first, "scope", scope);
}
return body;
}
} // namespace
OAuthTokenBroker::OAuthTokenBroker(const OAuthClientConfig& config,
HttpTransport& transport, OAuthSecretStore& secret_store,
std::function<std::int64_t()> clock)
: config_(config), transport_(transport), secret_store_(secret_store), clock_(clock) {}
OAuthTokens OAuthTokenBroker::valid_tokens(std::string_view account_id) {
// 1. Load stored tokens
std::optional<OAuthTokens> stored_opt;
try {
stored_opt = secret_store_.load(std::string(account_id));
} catch (const SecretStoreError&) {
throw;
} catch (const std::exception& e) {
throw std::runtime_error(std::string("failed to load tokens: ") + e.what());
}
if (!stored_opt.has_value()) {
throw std::runtime_error("account not connected: " + std::string(account_id));
}
const OAuthTokens& stored = *stored_opt;
const std::int64_t now = clock_();
// 2. Check if token is still valid (beyond now + 300s skew)
if (stored.expires_at_epoch_seconds > now + refresh_skew_seconds) {
return stored;
}
// 3. Refresh token
const std::string scope = join_scopes(config_.scopes);
const std::string body = make_refresh_body(config_, stored.refresh_token, scope);
HttpRequest request;
request.method = HttpMethod::post;
request.url = config_.token_endpoint;
request.headers = {{"Content-Type", "application/x-www-form-urlencoded"}};
request.body = body;
HttpResponse response;
try {
response = transport_.send(request);
} catch (const std::exception& e) {
throw std::runtime_error(std::string("transport error: ") + e.what());
}
// 4. Handle response
if (response.status == 200) {
OAuthTokens parsed;
try {
parsed = parse_oauth_token_response(response.body, now, stored.refresh_token);
} catch (const std::exception& e) {
throw std::runtime_error(std::string("parse error: ") + e.what());
}
// Persist first, then return
try {
secret_store_.store(std::string(account_id), parsed);
} catch (const std::exception& e) {
throw std::runtime_error(std::string("store error: ") + e.what());
}
return parsed;
} else if (response.status == 400 || response.status == 401) {
try {
secret_store_.erase(std::string(account_id));
} catch (const std::exception& e) {
throw std::runtime_error(std::string("erase error: ") + e.what());
}
throw AccountDisconnected("account " + std::string(account_id) + " disconnected");
} else {
throw std::runtime_error("unexpected status code: " + std::to_string(response.status));
}
}
} // namespace nocal::sync

View File

@@ -1,4 +1,5 @@
#include "nocal/tui/EventEditor.hpp" #include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/unicode.hpp"
#include <algorithm> #include <algorithm>
#include <array> #include <array>
@@ -7,6 +8,7 @@
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <ctime> #include <ctime>
#include <initializer_list>
#include <random> #include <random>
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
@@ -18,7 +20,9 @@ namespace {
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count); constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
constexpr std::array<std::string_view, field_count> labels{ constexpr std::array<std::string_view, field_count> labels{
"Title", "Start date", "Start time", "End date", "End time", "All day", "Location", "Notes", "Title", "Start date", "Start time", "End date", "End time",
"All day", "Location", "Notes",
"Repeat", "Interval", "Count", "On days",
}; };
std::size_t index_of(const EditorField field) noexcept std::size_t index_of(const EditorField field) noexcept
@@ -146,81 +150,6 @@ std::string single_line(std::string_view value)
return result; return result;
} }
struct DecodedCodepoint {
std::uint32_t value;
std::size_t length;
};
DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept
{
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80U) return {first, 1};
std::uint32_t value = 0;
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) {
value = first & 0x1fU;
length = 2;
} else if ((first & 0xf0U) == 0xe0U) {
value = first & 0x0fU;
length = 3;
} else if ((first & 0xf8U) == 0xf0U) {
value = first & 0x07U;
length = 4;
} else {
return {0xfffdU, 1};
}
if (at + length > text.size()) return {0xfffdU, 1};
for (std::size_t offset = 1; offset < length; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + offset]);
if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1};
value = (value << 6U) | (byte & 0x3fU);
}
return {value, length};
}
int codepoint_width(const std::uint32_t value) noexcept
{
if (value == 0 || (value >= 0x0300U && value <= 0x036fU) ||
(value >= 0x1ab0U && value <= 0x1affU) ||
(value >= 0x1dc0U && value <= 0x1dffU) ||
(value >= 0x20d0U && value <= 0x20ffU) ||
(value >= 0xfe00U && value <= 0xfe0fU) ||
(value >= 0xfe20U && value <= 0xfe2fU)) {
return 0;
}
if ((value >= 0x1100U && value <= 0x115fU) || value == 0x2329U || value == 0x232aU ||
(value >= 0x2e80U && value <= 0xa4cfU) ||
(value >= 0xac00U && value <= 0xd7a3U) ||
(value >= 0xf900U && value <= 0xfaffU) ||
(value >= 0xfe10U && value <= 0xfe19U) ||
(value >= 0xfe30U && value <= 0xfe6fU) ||
(value >= 0xff00U && value <= 0xff60U) ||
(value >= 0xffe0U && value <= 0xffe6U) ||
(value >= 0x2600U && value <= 0x27bfU) ||
(value >= 0x1f300U && value <= 0x1faffU) ||
(value >= 0x20000U && value <= 0x3fffdU)) {
return 2;
}
return 1;
}
std::string fit(std::string_view text, const int width)
{
if (width <= 0) return {};
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
const auto decoded = decode_codepoint(text, at);
const auto cell_width = codepoint_width(decoded.value);
if (used + cell_width > width) break;
result.append(text.substr(at, decoded.length));
at += decoded.length;
used += cell_width;
}
result.append(static_cast<std::size_t>(width - used), ' ');
return result;
}
std::string style(std::string text, const std::string_view code, const bool ansi) std::string style(std::string text, const std::string_view code, const bool ansi)
{ {
if (!ansi || text.empty()) return text; if (!ansi || text.empty()) return text;
@@ -231,7 +160,7 @@ std::string interior_line(std::string content, const int width)
{ {
if (width <= 0) return {}; if (width <= 0) return {};
if (width == 1) return ""; if (width == 1) return "";
return "" + fit(content, width - 2) + ""; return "" + fit_text(content, width - 2, false) + "";
} }
std::string styled_interior_line(std::string content, const int width, std::string styled_interior_line(std::string content, const int width,
@@ -239,7 +168,7 @@ std::string styled_interior_line(std::string content, const int width,
{ {
if (width <= 0) return {}; if (width <= 0) return {};
if (width == 1) return ""; if (width == 1) return "";
return "" + style(fit(content, width - 2), code, ansi) + ""; return "" + style(fit_text(content, width - 2, false), code, ansi) + "";
} }
std::string horizontal_line(const int width, const std::string_view left, std::string horizontal_line(const int width, const std::string_view left,
@@ -253,6 +182,124 @@ std::string horizontal_line(const int width, const std::string_view left,
return result; return result;
} }
struct KeyHint {
std::string_view key;
std::string_view label;
};
int hints_width(const KeyHint* const hints, const std::size_t count)
{
int width = 0;
for (std::size_t index = 0; index < count; ++index) {
if (index != 0) width += 3;
width += static_cast<int>(hints[index].key.size() + 1 + hints[index].label.size());
}
return width;
}
// Key bold, label dim, three spaces between hints; the plain layout is
// identical with ANSI disabled. Hints drop from the end to fit.
std::string render_hints(const std::initializer_list<KeyHint> hints, const int width,
const bool ansi)
{
std::size_t count = hints.size();
while (count > 0 && hints_width(hints.begin(), count) > width) --count;
std::string result;
for (std::size_t index = 0; index < count; ++index) {
if (index != 0) result += " ";
result += style(std::string{hints.begin()[index].key}, "1", ansi);
result += ' ';
result += style(std::string{hints.begin()[index].label}, "2", ansi);
}
const int used = hints_width(hints.begin(), count);
if (used < width) result.append(static_cast<std::size_t>(width - used), ' ');
return result;
}
std::string hints_interior_line(const std::initializer_list<KeyHint> hints, const int width,
const bool ansi)
{
if (width <= 0) return {};
if (width == 1) return "";
if (width <= 3) return interior_line({}, width);
return "" + render_hints(hints, width - 4, ansi) + "";
}
// Recurrence frequency display names
constexpr std::array<std::string_view, 5> frequency_names{
"none", "daily", "weekly", "monthly", "yearly",
};
[[nodiscard]] int frequency_name_to_index(const std::string_view name) noexcept
{
for (int i = 0; i < static_cast<int>(frequency_names.size()); ++i) {
if (frequency_names[static_cast<std::size_t>(i)] == name) return i;
}
return 0; // default to none
}
[[nodiscard]] std::string weekday_short_name(const std::chrono::weekday day) noexcept
{
static constexpr std::string_view names[]{"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
return std::string(names[day.c_encoding()]);
}
[[nodiscard]] std::string by_weekday_display(const std::vector<ByWeekday>& weekdays)
{
std::string result;
for (std::size_t i = 0; i < weekdays.size(); ++i) {
if (i != 0) result += ",";
result += weekday_short_name(weekdays[i].day);
}
return result;
}
// Parse a comma-separated list of weekday short names (MO,TU,WE) into ByWeekday entries.
// Returns empty vector on any parse failure.
[[nodiscard]] std::vector<ByWeekday> parse_by_weekday_input(const std::string_view text)
{
std::vector<ByWeekday> result;
if (text.empty()) return result;
std::size_t start = 0;
while (start < text.size()) {
while (start < text.size() && (text[start] == ',' || text[start] == ' ')) ++start;
if (start >= text.size()) break;
std::size_t end = start;
while (end < text.size() && text[end] != ',' && text[end] != ' ') ++end;
const auto token = text.substr(start, end - start);
// Check for ordinal prefix (e.g., 1MO, -1SU)
int ordinal = 0;
std::string_view weekday_part = token;
if (!token.empty() && (token[0] == '+' || token[0] == '-' || (token[0] >= '0' && token[0] <= '9'))) {
std::size_t digit_end = 0;
if (token[0] == '+' || token[0] == '-') digit_end = 1;
while (digit_end < token.size() && token[digit_end] >= '0' && token[digit_end] <= '9') ++digit_end;
if (digit_end > 0U && digit_end < token.size()) {
try {
ordinal = std::stoi(std::string(token.substr(0, digit_end)));
} catch (...) {
return {};
}
if (ordinal < -5 || ordinal > 5) return {};
weekday_part = token.substr(digit_end);
}
}
std::chrono::weekday day{0};
bool found = false;
if (weekday_part == "SU") { day = std::chrono::Sunday; found = true; }
else if (weekday_part == "MO") { day = std::chrono::Monday; found = true; }
else if (weekday_part == "TU") { day = std::chrono::Tuesday; found = true; }
else if (weekday_part == "WE") { day = std::chrono::Wednesday; found = true; }
else if (weekday_part == "TH") { day = std::chrono::Thursday; found = true; }
else if (weekday_part == "FR") { day = std::chrono::Friday; found = true; }
else if (weekday_part == "SA") { day = std::chrono::Saturday; found = true; }
if (!found) return {};
result.push_back({ordinal, day});
start = end;
}
return result;
}
} // namespace } // namespace
EventEditor::EventEditor(const Date selected_day) EventEditor::EventEditor(const Date selected_day)
@@ -266,6 +313,12 @@ EventEditor::EventEditor(const Date selected_day)
values_[index_of(EditorField::start_time)] = "09:00"; values_[index_of(EditorField::start_time)] = "09:00";
values_[index_of(EditorField::end_date)] = format_date(selected_day); values_[index_of(EditorField::end_date)] = format_date(selected_day);
values_[index_of(EditorField::end_time)] = "10:00"; values_[index_of(EditorField::end_time)] = "10:00";
values_[index_of(EditorField::recurrence_frequency)] = "none";
values_[index_of(EditorField::recurrence_interval)] = "1";
values_[index_of(EditorField::recurrence_count)] = "";
// Default to days of week for weekly: the start day's weekday
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{selected_day}};
values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd);
} }
EventEditor::EventEditor(const Event& event) EventEditor::EventEditor(const Event& event)
@@ -281,11 +334,34 @@ EventEditor::EventEditor(const Event& event)
values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute); values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute);
values_[index_of(EditorField::location)] = event.location; values_[index_of(EditorField::location)] = event.location;
values_[index_of(EditorField::notes)] = event.description; values_[index_of(EditorField::notes)] = event.description;
// Populate recurrence fields from existing rule
if (event.recurrence) {
const auto& rule = *event.recurrence;
std::string freq_name;
switch (rule.frequency) {
case RecurrenceFrequency::daily: freq_name = "daily"; break;
case RecurrenceFrequency::weekly: freq_name = "weekly"; break;
case RecurrenceFrequency::monthly: freq_name = "monthly"; break;
case RecurrenceFrequency::yearly: freq_name = "yearly"; break;
}
values_[index_of(EditorField::recurrence_frequency)] = freq_name;
values_[index_of(EditorField::recurrence_interval)] = std::to_string(rule.interval);
values_[index_of(EditorField::recurrence_count)] = rule.count
? std::to_string(*rule.count) : "";
values_[index_of(EditorField::recurrence_by_weekday)] = by_weekday_display(rule.by_weekday);
} else {
values_[index_of(EditorField::recurrence_frequency)] = "none";
values_[index_of(EditorField::recurrence_interval)] = "1";
values_[index_of(EditorField::recurrence_count)] = "";
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start.date}};
values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd);
}
} }
std::string_view EventEditor::field_value(const EditorField field) const noexcept std::string_view EventEditor::field_value(const EditorField field) const noexcept
{ {
if (field == EditorField::all_day || field == EditorField::count) return {}; if (field == EditorField::all_day) return {};
return values_[index_of(field)]; return values_[index_of(field)];
} }
@@ -336,6 +412,18 @@ EditorOutcome EventEditor::handle_key(const std::string_view key)
return EditorOutcome::active; return EditorOutcome::active;
} }
// Recurrence frequency cycling: Space or arrow keys cycle through options
if (focused_ == EditorField::recurrence_frequency) {
if (key == " " || key == "\r" || key == "\n") {
auto& value = values_[index_of(focused_)];
const int current = frequency_name_to_index(value);
const int next = (current + 1) % static_cast<int>(frequency_names.size());
value = frequency_names[static_cast<std::size_t>(next)];
error_.clear();
}
return EditorOutcome::active;
}
auto& value = values_[index_of(focused_)]; auto& value = values_[index_of(focused_)];
if (key == "\x7f" || key == "\b") { if (key == "\x7f" || key == "\b") {
erase_last_codepoint(value); erase_last_codepoint(value);
@@ -389,8 +477,6 @@ bool EventEditor::validate()
return fail(EditorField::end_date, return fail(EditorField::end_date,
"All-day end is exclusive and must be at least the next day."); "All-day end is exclusive and must be at least the next day.");
} }
// DATE values have no timezone in iCalendar. Normalizing here keeps
// the in-memory result identical to a save/reload round trip.
candidate.time_basis = TimeBasis::floating; candidate.time_basis = TimeBasis::floating;
candidate.time_zone.clear(); candidate.time_zone.clear();
candidate.start = make_event_time(candidate, start_day, 0, 0); candidate.start = make_event_time(candidate, start_day, 0, 0);
@@ -417,6 +503,68 @@ bool EventEditor::validate()
"That local date and time cannot be represented in this time zone."); "That local date and time cannot be represented in this time zone.");
} }
// Build recurrence rule from editor fields
const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)];
if (freq_text != "none") {
RecurrenceRule rule;
// Parse frequency
if (freq_text == "daily") rule.frequency = RecurrenceFrequency::daily;
else if (freq_text == "weekly") rule.frequency = RecurrenceFrequency::weekly;
else if (freq_text == "monthly") rule.frequency = RecurrenceFrequency::monthly;
else if (freq_text == "yearly") rule.frequency = RecurrenceFrequency::yearly;
else {
return fail(EditorField::recurrence_frequency, "Invalid recurrence frequency.");
}
// Parse interval
const auto& interval_text = values_[index_of(EditorField::recurrence_interval)];
if (interval_text.empty()) {
return fail(EditorField::recurrence_interval, "Interval is required.");
}
try {
const auto interval_val = std::stoul(interval_text);
if (interval_val < 1U || interval_val > 32767U) {
return fail(EditorField::recurrence_interval, "Interval must be between 1 and 32767.");
}
rule.interval = static_cast<unsigned>(interval_val);
} catch (const std::exception&) {
return fail(EditorField::recurrence_interval, "Interval must be a positive integer.");
}
// Parse count (optional)
const auto& count_text = values_[index_of(EditorField::recurrence_count)];
if (!count_text.empty()) {
try {
const auto count_val = std::stoul(count_text);
if (count_val < 1U) {
return fail(EditorField::recurrence_count, "Count must be at least 1.");
}
rule.count = static_cast<unsigned>(count_val);
} catch (const std::exception&) {
return fail(EditorField::recurrence_count, "Count must be a positive integer or empty.");
}
}
// Parse BYDAY
const auto& by_weekday_text = values_[index_of(EditorField::recurrence_by_weekday)];
const auto parsed = parse_by_weekday_input(by_weekday_text);
if (parsed.empty() && !by_weekday_text.empty()) {
return fail(EditorField::recurrence_by_weekday, "Use comma-separated day names: MO,TU,WE");
}
rule.by_weekday = parsed;
// If frequency is weekly and by_weekday is empty, fill with DTSTART's weekday
if (rule.frequency == RecurrenceFrequency::weekly && rule.by_weekday.empty()) {
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start_day}};
rule.by_weekday.push_back({0, start_wd});
}
candidate.recurrence = std::move(rule);
} else {
candidate.recurrence = std::nullopt;
}
result_ = std::move(candidate); result_ = std::move(candidate);
error_.clear(); error_.clear();
return true; return true;
@@ -440,8 +588,9 @@ std::string EventEditor::render(const int requested_width, const int requested_h
: editing_ ? " NOCAL · EDIT APPOINTMENT" : editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT"; : " NOCAL · NEW APPOINTMENT";
lines.push_back(styled_interior_line(heading, width, "1", ansi)); lines.push_back(styled_interior_line(heading, width, "1", ansi));
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle", lines.push_back(hints_interior_line(
width, "2", ansi)); {{"Tab", "next field"}, {"Shift-Tab", "previous field"}, {"Space", "toggle"}},
width, ansi));
lines.push_back(styled_interior_line( lines.push_back(styled_interior_line(
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty() original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
? " Times shown in source zone: " + original_.time_zone ? " Times shown in source zone: " + original_.time_zone
@@ -449,29 +598,53 @@ std::string EventEditor::render(const int requested_width, const int requested_h
width, "2", ansi)); width, "2", ansi));
lines.push_back(rule); lines.push_back(rule);
const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)];
const bool has_recurrence = freq_text != "none";
for (std::size_t index = 0; index < field_count; ++index) { for (std::size_t index = 0; index < field_count; ++index) {
const auto field = static_cast<EditorField>(index); const auto field = static_cast<EditorField>(index);
const bool selected = focused_ == field; const bool selected = focused_ == field;
// Suppress recurrence fields (except frequency) when frequency is none
if (field == EditorField::recurrence_interval && !has_recurrence) continue;
if (field == EditorField::recurrence_count && !has_recurrence) continue;
if (field == EditorField::recurrence_by_weekday && !has_recurrence) continue;
std::string value; std::string value;
if (field == EditorField::all_day) { if (field == EditorField::all_day) {
value = all_day_ ? "[x] spans whole days (end date is exclusive)" value = all_day_ ? "[x] spans whole days (end date is exclusive)"
: "[ ] timed appointment"; : "[ ] timed appointment";
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) { } else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
value = "— not used for all-day appointments —"; value = "— not used for all-day appointments —";
} else if (field == EditorField::recurrence_frequency) {
value = std::string{freq_text};
if (selected) value += " (Space to cycle)";
} else if (field == EditorField::recurrence_interval) {
value = values_[index];
if (selected) value += "_";
} else if (field == EditorField::recurrence_count) {
const auto& count_val = values_[index];
value = count_val.empty() ? "(unlimited)" : count_val;
if (selected) value += "_";
} else if (field == EditorField::recurrence_by_weekday) {
value = values_[index];
if (selected) value += "_";
} else { } else {
value = single_line(values_[index]); value = single_line(values_[index]);
if (selected) value += "_"; if (selected) value += "_";
} }
std::string row = selected ? " " : " "; std::string row = selected ? " " : " ";
row += fit(labels[index], 11); row += fit_text(labels[index], 11, false);
row += " "; row += " ";
row += value; row += value;
lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi)); lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi));
} }
lines.push_back(rule); lines.push_back(rule);
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width)); lines.push_back(error_.empty()
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi)); ? styled_interior_line(" Ready", width, "2", ansi)
: styled_interior_line(" ! " + error_, width, "1;31", ansi));
lines.push_back(hints_interior_line({{"Ctrl-S", "save"}, {"Esc", "cancel"}}, width, ansi));
lines.push_back(bottom); lines.push_back(bottom);
// Keep the active field visible on short terminals by selecting a window // Keep the active field visible on short terminals by selecting a window
@@ -505,4 +678,4 @@ std::string EventEditor::render(const int requested_width, const int requested_h
return frame; return frame;
} }
} // namespace nocal::tui } // namespace nocal::tui

View File

@@ -1,4 +1,5 @@
#include "nocal/tui/Screen.hpp" #include "nocal/tui/Screen.hpp"
#include "nocal/tui/unicode.hpp"
#include <algorithm> #include <algorithm>
#include <array> #include <array>
@@ -17,72 +18,14 @@ using namespace std::chrono;
constexpr std::array<std::string_view, 12> month_names{ constexpr std::array<std::string_view, 12> month_names{
"January", "February", "March", "April", "May", "June", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"}; "July", "August", "September", "October", "November", "December"};
constexpr std::array<std::string_view, 12> month_short{
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
constexpr std::array<std::string_view, 7> weekday_long{ constexpr std::array<std::string_view, 7> weekday_long{
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}; "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
constexpr std::array<std::string_view, 7> weekday_short{ constexpr std::array<std::string_view, 7> weekday_short{
"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}; "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"};
std::uint32_t decode_codepoint(const std::string_view text, const std::size_t at,
std::size_t& length) {
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80) {
length = 1;
return first;
}
int continuation = 0;
std::uint32_t result = 0;
if ((first & 0xe0) == 0xc0) {
continuation = 1;
result = first & 0x1f;
} else if ((first & 0xf0) == 0xe0) {
continuation = 2;
result = first & 0x0f;
} else if ((first & 0xf8) == 0xf0) {
continuation = 3;
result = first & 0x07;
} else {
length = 1;
return 0xfffd;
}
if (at + static_cast<std::size_t>(continuation) >= text.size()) {
length = 1;
return 0xfffd;
}
for (int offset = 1; offset <= continuation; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + static_cast<std::size_t>(offset)]);
if ((byte & 0xc0) != 0x80) {
length = 1;
return 0xfffd;
}
result = (result << 6) | (byte & 0x3f);
}
length = static_cast<std::size_t>(continuation + 1);
return result;
}
int codepoint_width(const std::uint32_t value) {
if ((value >= 0x0300 && value <= 0x036f) || (value >= 0xfe00 && value <= 0xfe0f)) {
return 0;
}
if ((value >= 0x1100 && value <= 0x115f) || (value >= 0x2e80 && value <= 0xa4cf) ||
(value >= 0xac00 && value <= 0xd7a3) || (value >= 0xf900 && value <= 0xfaff) ||
(value >= 0x1f300 && value <= 0x1faff)) {
return 2;
}
return 1;
}
int display_width(const std::string_view text) {
int width = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
width += codepoint_width(cp);
at += length;
}
return width;
}
std::string printable_text(const std::string_view text, const bool preserve_newlines = false) { std::string printable_text(const std::string_view text, const bool preserve_newlines = false) {
std::string result{text}; std::string result{text};
for (auto& character : result) { for (auto& character : result) {
@@ -94,40 +37,6 @@ std::string printable_text(const std::string_view text, const bool preserve_newl
return result; return result;
} }
std::string fit_text(const std::string_view text, const int requested_width,
const bool ellipsis = true) {
const int width = std::max(0, requested_width);
if (width == 0) {
return {};
}
if (display_width(text) <= width) {
std::string result{text};
result.append(static_cast<std::size_t>(width - display_width(text)), ' ');
return result;
}
const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0));
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
const int cp_width = codepoint_width(cp);
if (used + cp_width > content_limit) {
break;
}
result.append(text.substr(at, length));
used += cp_width;
at += length;
}
if (ellipsis && width > 1) {
result += "";
++used;
}
result.append(static_cast<std::size_t>(std::max(0, width - used)), ' ');
return result;
}
std::string centred(const std::string_view text, const int width) { std::string centred(const std::string_view text, const int width) {
const int visible = std::min(display_width(text), width); const int visible = std::min(display_width(text), width);
const int left = std::max(0, (width - visible) / 2); const int left = std::max(0, (width - visible) / 2);
@@ -143,6 +52,62 @@ std::string styled(const std::string_view text, const std::string_view code, con
return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m"; return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m";
} }
struct KeyHint {
std::string_view key;
std::string_view label;
};
int hints_display_width(const std::span<const KeyHint> hints) {
int total = 0;
for (std::size_t index = 0; index < hints.size(); ++index) {
if (index != 0) total += 3; // three spaces between hints
total += display_width(hints[index].key) + 1 + display_width(hints[index].label);
}
return total;
}
// Renders hint chips in at most `width` columns, dropping hints from the end
// (lowest priority) until the rest fit, then pads to exactly `width`.
std::string render_hints(const std::span<const KeyHint> hints, const int width,
const bool ansi) {
const int w = std::max(0, width);
std::size_t shown = hints.size();
while (shown > 0 && hints_display_width(hints.first(shown)) > w) --shown;
std::string result;
for (std::size_t index = 0; index < shown; ++index) {
if (index != 0) result += " ";
result += styled(hints[index].key, "1", ansi);
result += ' ';
result += styled(hints[index].label, "2", ansi);
}
const int used = hints_display_width(hints.first(shown));
if (used < w) result.append(static_cast<std::size_t>(w - used), ' ');
return result;
}
// Status left-aligned in its own style, hints right-aligned with at least two
// spaces between. The result is always exactly `width` columns wide.
std::string status_bar(const std::string_view status, const std::string_view status_style,
const std::span<const KeyHint> hints, const int width, const bool ansi) {
const int w = std::max(0, width);
if (w == 0) return {};
std::size_t shown = hints.size();
while (shown > 0 &&
display_width(status) + 2 + hints_display_width(hints.first(shown)) > w) {
--shown;
}
const int hints_width = hints_display_width(hints.first(shown));
const int status_budget = shown > 0 ? std::max(0, w - 2 - hints_width) : w;
const std::string fitted = fit_text(status, status_budget);
std::string result = fitted.empty() ? std::string{} : styled(fitted, status_style, ansi);
if (shown > 0) {
const int gap = std::max(2, w - display_width(fitted) - hints_width);
result.append(static_cast<std::size_t>(gap), ' ');
result += render_hints(hints.first(shown), hints_width, ansi);
}
return result;
}
std::string two_digits(const unsigned value) { std::string two_digits(const unsigned value) {
std::array<char, 3> result{'0', '0', '\0'}; std::array<char, 3> result{'0', '0', '\0'};
result[0] = static_cast<char>('0' + (value / 10) % 10); result[0] = static_cast<char>('0' + (value / 10) % 10);
@@ -261,18 +226,17 @@ std::vector<std::string> wrap_text(const std::string_view text, const int reques
std::string part; std::string part;
int used = 0; int used = 0;
while (word_at < word.size()) { while (word_at < word.size()) {
std::size_t length = 1; const auto decoded = decode_codepoint(word, word_at);
const auto cp = decode_codepoint(word, word_at, length); const auto cp_width = codepoint_width(decoded.value);
const auto cp_width = codepoint_width(cp);
if (used + cp_width > width) { if (used + cp_width > width) {
if (used == 0) { if (used == 0) {
part.append(word.substr(word_at, length)); part.append(word.substr(word_at, decoded.length));
word_at += length; word_at += decoded.length;
} }
break; break;
} }
part.append(word.substr(word_at, length)); part.append(word.substr(word_at, decoded.length));
word_at += length; word_at += decoded.length;
used += cp_width; used += cp_width;
} }
if (word_at < word.size() || !part.empty()) result.push_back(part); if (word_at < word.size() || !part.empty()) result.push_back(part);
@@ -391,15 +355,21 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
if (!focused.description.empty()) { if (!focused.description.empty()) {
add(" NOTES", "2;1"); add(" NOTES", "2;1");
for (const auto& line : wrap_text(printable_text(focused.description, true), for (const auto& line : wrap_text(printable_text(focused.description, true),
std::max(1, inner - 2))) { std::max(1, inner - 2))) {
add(" " + line); add(" " + line);
} }
} else { } else {
add(" No notes for this appointment.", "2;3"); add(" No notes for this appointment.", "2;3");
} }
const auto footer = " ↑↓/Tab browse / search e edit d delete u undo Ctrl-R redo Esc back " + const auto footer_hints = std::array{
std::to_string(ordinal) + "/" + std::to_string(total) + " "; KeyHint{"Tab", "next"},
KeyHint{"/", "search"},
KeyHint{"e", "edit"},
KeyHint{"d", "del"},
KeyHint{"Esc", "back"},
};
const auto ordinal_str = std::to_string(ordinal) + "/" + std::to_string(total);
const int body_rows = height - 2; const int body_rows = height - 2;
std::ostringstream output; std::ostringstream output;
output << border({inner}, "", "", "") << '\n'; output << border({inner}, "", "", "") << '\n';
@@ -407,8 +377,24 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
std::string value(static_cast<std::size_t>(inner), ' '); std::string value(static_cast<std::size_t>(inner), ' ');
std::string style; std::string style;
if (line == body_rows - 1) { if (line == body_rows - 1) {
value = fit_text(footer, inner); // Hints left, ordinal right; both drop gracefully on narrow frames.
style = "7"; const int ordinal_width = display_width(ordinal_str);
std::size_t shown = footer_hints.size();
while (shown > 0 &&
hints_display_width(std::span{footer_hints}.first(shown)) + 2 +
ordinal_width >
inner) {
--shown;
}
const int hints_width =
hints_display_width(std::span{footer_hints}.first(shown));
const bool show_ordinal =
ordinal_width + (shown > 0 ? hints_width + 2 : 0) <= inner;
value = render_hints(std::span{footer_hints}.first(shown), hints_width,
state.ansi);
const int padding = inner - hints_width - (show_ordinal ? ordinal_width : 0);
value.append(static_cast<std::size_t>(std::max(0, padding)), ' ');
if (show_ordinal) value += styled(ordinal_str, "2", state.ansi);
} else if (line < static_cast<int>(content.size())) { } else if (line < static_cast<int>(content.size())) {
value = content[static_cast<std::size_t>(line)].first; value = content[static_cast<std::size_t>(line)].first;
style = content[static_cast<std::size_t>(line)].second; style = content[static_cast<std::size_t>(line)].second;
@@ -429,12 +415,17 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
const int inner = width - 2; const int inner = width - 2;
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title; const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"} const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title); : printable_text(detail_title);
const auto question = fit_text(focused.recurring const auto question = fit_text(focused.recurring
? " Delete entire recurring series “" + title + "”?" ? " Delete entire recurring series “" + title + "”?"
: " Delete “" + title + "”?", : " Delete “" + title + "”?",
inner); inner);
const auto controls = fit_text(" y confirm n/Esc cancel", inner); const std::array confirm_hints{
KeyHint{"y", "confirm"},
KeyHint{"n", "cancel"},
KeyHint{"Esc", "cancel"},
};
const auto controls = render_hints(confirm_hints, inner, state.ansi);
const int question_line = std::max(1, height / 2 - 1); const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output; std::ostringstream output;
output << border({inner}, "", "", "") << '\n'; output << border({inner}, "", "", "") << '\n';
@@ -446,7 +437,6 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
style = "1;31"; style = "1;31";
} else if (line == question_line + 2) { } else if (line == question_line + 2) {
content = controls; content = controls;
style = "7";
} else if (!state.notification.empty() && line == question_line + 3) { } else if (!state.notification.empty() && line == question_line + 3) {
content = fit_text(" " + printable_text(state.notification, true), inner); content = fit_text(" " + printable_text(state.notification, true), inner);
style = state.notification_is_error ? "1;31" : "1;32"; style = state.notification_is_error ? "1;31" : "1;32";
@@ -482,7 +472,7 @@ std::string recurrence_summary(const RecurrenceRule& rule) {
result += " on "; result += " on ";
for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) { for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) {
if (index != 0) result += ", "; if (index != 0) result += ", ";
const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U; const auto weekday_index = rule.by_weekday[index].day.iso_encoding() - 1U;
if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index]; if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index];
} }
} }
@@ -531,19 +521,23 @@ std::string search_frame(const ScreenState& state) {
if (state.search_prompt) { if (state.search_prompt) {
if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_", if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_",
width, false)); width, false));
if (height > 2) lines.push_back(styled(fit_text( if (height > 2) lines.push_back(styled(fit_text(
"Search title, description, location, or calendar ID", width), "2", state.ansi)); "Search title, description, location, or calendar ID", width), "2", state.ansi));
if (height > 3) lines.push_back(styled(fit_text( const std::array prompt_hints{
"Enter search Backspace edit Esc cancel", width), "2", state.ansi)); KeyHint{"Enter", "search"},
KeyHint{"Backspace", "edit"},
KeyHint{"Esc", "cancel"},
};
if (height > 3) lines.push_back(render_hints(prompt_hints, width, state.ansi));
} else { } else {
const auto count = state.search_results.size(); const auto count = state.search_results.size();
const auto summary = count == 0 const auto summary = count == 0
? "No matches for “" + printable_text(state.search_query) + "" ? "No matches for “" + printable_text(state.search_query) + ""
: std::to_string(count) + (count == 1 ? " match for “" : " matches for “") + : std::to_string(count) + (count == 1 ? " match for “" : " matches for “") +
printable_text(state.search_query) + ""; printable_text(state.search_query) + "";
if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36", if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36",
state.ansi)); state.ansi));
const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 4)); const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 4));
std::size_t first = 0; std::size_t first = 0;
if (row_capacity > 0 && state.search_result_index >= row_capacity) { if (row_capacity > 0 && state.search_result_index >= row_capacity) {
@@ -568,8 +562,13 @@ std::string search_frame(const ScreenState& state) {
} }
if (height > 2) lines.push_back(styled(fit_text( if (height > 2) lines.push_back(styled(fit_text(
"Five years either side of the selected date", width), "2", state.ansi)); "Five years either side of the selected date", width), "2", state.ansi));
if (height > 1) lines.push_back(styled(fit_text( const std::array results_hints{
"↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi)); KeyHint{"↑↓", "choose"},
KeyHint{"Enter", "jump"},
KeyHint{"/", "new search"},
KeyHint{"Esc", "close"},
};
if (height > 1) lines.push_back(render_hints(results_hints, width, state.ansi));
} }
while (static_cast<int>(lines.size()) < height) { while (static_cast<int>(lines.size()) < height) {
@@ -602,7 +601,7 @@ std::string agenda_frame(const ScreenState& state) {
std::vector<std::size_t> ordered(state.agenda_items.size()); std::vector<std::size_t> ordered(state.agenda_items.size());
for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index; for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index;
std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_index, std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_index,
const auto right_index) { const auto right_index) {
const auto& left = state.agenda_items[left_index]; const auto& left = state.agenda_items[left_index];
const auto& right = state.agenda_items[right_index]; const auto& right = state.agenda_items[right_index];
if (left.day != right.day) return sys_days{left.day} < sys_days{right.day}; if (left.day != right.day) return sys_days{left.day} < sys_days{right.day};
@@ -611,13 +610,13 @@ std::string agenda_frame(const ScreenState& state) {
right.time_of_day.value_or(minutes{-1}); right.time_of_day.value_or(minutes{-1});
}); });
const auto footer_rows = std::min(2, std::max(0, height - 2)); const auto footer_rows = 1;
const auto row_capacity = static_cast<std::size_t>( const auto row_capacity = static_cast<std::size_t>(
std::max(0, height - 2 - footer_rows)); std::max(0, height - 2 - footer_rows));
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index); const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
const auto selected_position = selected == ordered.end() const auto selected_position = selected == ordered.end()
? std::size_t{0} ? std::size_t{0}
: static_cast<std::size_t>(selected - ordered.begin()); : static_cast<std::size_t>(selected - ordered.begin());
std::size_t first = 0; std::size_t first = 0;
if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) { if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) {
first = selected_position - row_capacity + 1; first = selected_position - row_capacity + 1;
@@ -645,13 +644,106 @@ std::string agenda_frame(const ScreenState& state) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
} }
if (footer_rows > 0) { if (footer_rows > 0) {
lines.push_back(styled(fit_text( const std::array agenda_hints{
"arrows/jk/Tab choose Enter month n/p 6 weeks", KeyHint{"↑↓", "choose"},
width), "2", state.ansi)); KeyHint{"Enter", "month"},
KeyHint{"n/p", "window"},
KeyHint{"/", "search"},
KeyHint{"c", "calendars"},
KeyHint{"g", "close"},
};
lines.push_back(render_hints(agenda_hints, width, state.ansi));
} }
if (footer_rows > 1) { while (static_cast<int>(lines.size()) < height) {
lines.push_back(styled(fit_text( lines.emplace_back(static_cast<std::size_t>(width), ' ');
"c calendars / search g/Esc close", width), "2", state.ansi)); }
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
std::string linear_frame(const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
std::vector<std::string> lines;
lines.reserve(static_cast<std::size_t>(height));
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
const auto month_name = std::string{month_names[month_index]};
const auto year_str = std::to_string(static_cast<int>(state.visible_month.year()));
const auto count = state.agenda_items.size();
const auto header = "LINEAR VIEW \u2014 " + month_name + " " + year_str +
" (" + std::to_string(count) + " event" + (count == 1 ? "" : "s") + ")";
lines.push_back(styled(centred(header, width), "1", state.ansi));
std::vector<std::size_t> ordered(state.agenda_items.size());
for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index;
std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_index,
const auto right_index) {
const auto& left = state.agenda_items[left_index];
const auto& right = state.agenda_items[right_index];
if (left.day != right.day) return sys_days{left.day} < sys_days{right.day};
if (left.all_day != right.all_day) return left.all_day;
return left.time_of_day.value_or(minutes{-1}) <
right.time_of_day.value_or(minutes{-1});
});
const auto footer_rows = 1;
const auto row_capacity = static_cast<std::size_t>(
std::max(0, height - 2 - footer_rows));
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
const auto selected_position = selected == ordered.end()
? std::size_t{0}
: static_cast<std::size_t>(selected - ordered.begin());
std::size_t first = 0;
if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) {
first = selected_position - row_capacity + 1;
}
const auto last = std::min(ordered.size(), first + row_capacity);
for (auto position = first; position < last; ++position) {
const auto index = ordered[position];
const auto& item = state.agenda_items[index];
const bool focused = index == state.agenda_index;
const auto number = position - first + 1;
auto when = item.all_day || !item.time_of_day
? std::string{"all day"}
: clock_time(*item.time_of_day) + " local";
auto title = item.title.empty() ? std::string{"(untitled)"}
: printable_text(item.title);
std::string prefix;
if (focused && !state.ansi) {
prefix = "[*>]";
} else {
prefix = "[" + std::to_string(number) + "]";
}
auto label = prefix + " " + iso_date(item.day) + " " + when + " " + title;
if (item.recurring) label += " (recurring)";
if (!item.location.empty()) label += " - " + printable_text(item.location);
lines.push_back(styled(fit_text(label, width), focused ? "1" : "", state.ansi));
}
if (state.agenda_items.empty() && row_capacity > 0) {
lines.push_back(styled(centred("No appointments.", width), "2", state.ansi));
}
while (static_cast<int>(lines.size()) < height - footer_rows) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (footer_rows > 0) {
const std::array linear_hints{
KeyHint{"\xe2\x86\x91\xe2\x86\x93", "navigate"},
KeyHint{"Enter", "month"},
KeyHint{"n/p", "window"},
KeyHint{"L/Esc", "close"},
};
lines.push_back(render_hints(linear_hints, width, state.ansi));
} }
while (static_cast<int>(lines.size()) < height) { while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
@@ -703,8 +795,12 @@ std::string calendar_picker_frame(const std::span<const Calendar> calendars,
lines.emplace_back(static_cast<std::size_t>(width), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
} }
if (height > 1) { if (height > 1) {
lines.push_back(styled(fit_text( const std::array picker_hints{
"↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi)); KeyHint{"↑↓", "choose"},
KeyHint{"Enter", "toggle"},
KeyHint{"c", "close"},
};
lines.push_back(render_hints(picker_hints, width, state.ansi));
} }
while (static_cast<int>(lines.size()) < height) { while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
@@ -719,6 +815,119 @@ std::string calendar_picker_frame(const std::span<const Calendar> calendars,
return output.str(); return output.str();
} }
struct HelpLine {
std::string plain; // exact cell accounting
std::string rich; // styled; identical layout when ANSI is disabled
};
std::string help_frame(const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
struct HelpGroup {
std::string_view title;
std::vector<std::pair<std::string_view, std::string_view>> rows;
};
const std::array groups{
HelpGroup{"NAVIGATE",
{{"arrows / hjkl", "Move between days"},
{"PgUp/PgDn or n/p", "Change month"},
{"t", "Jump to today"},
{"Tab / Shift-Tab", "Focus appointments"},
{"Enter", "Read focused appointment"}}},
HelpGroup{"APPOINTMENTS",
{{"a", "Add appointment"},
{"e", "Edit focused appointment"},
{"d", "Delete focused appointment"},
{"u", "Undo last change"},
{"Ctrl-R", "Redo last change"}}},
HelpGroup{"VIEWS",
{{"g", "Agenda (42 days)"},
{"L", "Linear view (accessible)"},
{"c", "Calendar visibility"},
{"/", "Search appointments"}}},
HelpGroup{"GENERAL",
{{"?", "Toggle this help"}, {"Esc", "Back / close"}, {"q", "Quit"}}},
};
int key_column = 0;
for (const auto& group : groups) {
for (const auto& [key, description] : group.rows) {
key_column = std::max(key_column, display_width(key));
}
}
const auto make_column = [&](const std::span<const HelpGroup> column_groups,
const int column_width) {
std::vector<HelpLine> column;
for (const auto& group : column_groups) {
const std::string title{group.title};
column.push_back({title, styled(title, "2;1", state.ansi)});
for (const auto& [key, description] : group.rows) {
std::string padded_key{key};
padded_key.append(static_cast<std::size_t>(key_column - display_width(key)),
' ');
const int description_budget = std::max(0, column_width - 3 - key_column);
const std::string fitted = fit_text(description, description_budget);
column.push_back({" " + padded_key + " " + fitted,
" " + styled(padded_key, "1", state.ansi) + " " + fitted});
}
}
return column;
};
std::vector<HelpLine> content;
if (width >= 72) {
const int column_width = (width - 4) / 2;
auto left = make_column(std::span{groups}.subspan(0, 2), column_width);
auto right = make_column(std::span{groups}.subspan(2, 2), column_width);
const HelpLine blank{std::string(static_cast<std::size_t>(column_width), ' '),
std::string(static_cast<std::size_t>(column_width), ' ')};
const auto rows = std::max(left.size(), right.size());
left.resize(rows, blank);
right.resize(rows, blank);
content.reserve(rows);
for (std::size_t row = 0; row < rows; ++row) {
content.push_back({left[row].plain + " " + right[row].plain,
left[row].rich + " " + right[row].rich});
}
} else {
content = make_column(std::span{groups}, width);
}
std::vector<std::string> lines;
lines.reserve(static_cast<std::size_t>(height));
lines.push_back(styled(centred("KEYBOARD SHORTCUTS", width), "1", state.ansi));
if (height > 2) lines.emplace_back(static_cast<std::size_t>(width), ' ');
const int content_budget = std::max(0, height - static_cast<int>(lines.size()) - 1);
if (static_cast<int>(content.size()) > content_budget) {
content.resize(static_cast<std::size_t>(content_budget));
}
for (const auto& [plain, rich] : content) {
std::string line = rich;
const int plain_width = display_width(plain);
if (plain_width < width) {
line.append(static_cast<std::size_t>(width - plain_width), ' ');
}
lines.push_back(std::move(line));
}
while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (height > 1) {
const std::array close_hints{KeyHint{"?", "close"}, KeyHint{"Esc", "close"}};
lines.push_back(status_bar("", {}, close_hints, width, state.ansi));
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) { std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width); const int width = std::max(1, state.width);
const int height = std::max(1, state.height); const int height = std::max(1, state.height);
@@ -773,18 +982,25 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
while (static_cast<int>(lines.size()) < height - 1) { while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
} }
std::string controls;
if (!state.notification.empty()) {
controls = printable_text(state.notification, true);
} else if (focused == nullptr) {
controls = "g agenda c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
} else {
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
}
if (static_cast<int>(lines.size()) < height) { if (static_cast<int>(lines.size()) < height) {
const auto style = state.notification.empty() ? "2" if (!state.notification.empty()) {
: state.notification_is_error ? "1;31" : "1;32"; const auto text = (state.notification_is_error ? std::string{"! "} : std::string{""}) +
lines.push_back(styled(fit_text(controls, width), style, state.ansi)); printable_text(state.notification, true);
lines.push_back(styled(fit_text(text, width),
state.notification_is_error ? "1;31" : "1;32", state.ansi));
} else if (focused == nullptr) {
const std::array no_focus_hints{
KeyHint{"g", "agenda"}, KeyHint{"c", "cals"}, KeyHint{"/", "search"},
KeyHint{"a", "add"}, KeyHint{"?", "help"}, KeyHint{"q", "quit"},
};
lines.push_back(render_hints(no_focus_hints, width, state.ansi));
} else {
const std::array focus_hints{
KeyHint{"Tab", "appt"}, KeyHint{"Enter", "open"},
KeyHint{"e", "edit"}, KeyHint{"d", "del"},
};
lines.push_back(render_hints(focus_hints, width, state.ansi));
}
} }
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height)); if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
@@ -830,12 +1046,15 @@ Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "\x12") return Action::redo; if (sequence == "\x12") return Action::redo;
if (sequence == "/") return Action::search; if (sequence == "/") return Action::search;
if (sequence == "g") return Action::agenda; if (sequence == "g") return Action::agenda;
if (sequence == "L") return Action::linear_view;
if (sequence == "c") return Action::calendars; if (sequence == "c") return Action::calendars;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
if (sequence == "\x13") return Action::sync;
return Action::none; return Action::none;
} }
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) { std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
if (state.show_linear_view) return linear_frame(state);
if (state.search_prompt || state.show_search_results) return search_frame(state); if (state.search_prompt || state.show_search_results) return search_frame(state);
if (state.show_agenda) return agenda_frame(state); if (state.show_agenda) return agenda_frame(state);
const auto focused = focused_item(items, state); const auto focused = focused_item(items, state);
@@ -848,6 +1067,9 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
if (state.width < 35 || state.height < 16) { if (state.width < 35 || state.height < 16) {
return compact_frame(items, state); return compact_frame(items, state);
} }
if (state.show_help) {
return help_frame(state);
}
const int width = state.width; const int width = state.width;
const int inner_total = width - 8; // eight vertical border glyphs const int inner_total = width - 8; // eight vertical border glyphs
@@ -875,16 +1097,25 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
} }
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1; const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
const auto title = " " + std::string{month_names[month_index]} + " " + const auto month_title = std::string{month_names[month_index]} + " " +
std::to_string(static_cast<int>(state.visible_month.year())) + " "; std::to_string(static_cast<int>(state.visible_month.year()));
const auto plain_title = " " + month_title + " ";
const int title_width = display_width(plain_title);
const int title_pad = std::max(0, (width - title_width) / 2);
std::ostringstream output; std::ostringstream output;
output << styled(centred(title, width), "1", state.ansi) << '\n'; output << std::string(static_cast<std::size_t>(title_pad), ' ')
<< styled("", "2", state.ansi) << " " << styled(month_title, "1", state.ansi)
<< " " << styled("", "2", state.ansi);
if (title_pad + title_width < width) {
output << std::string(static_cast<std::size_t>(width - title_pad - title_width), ' ');
}
output << '\n';
output << ' '; output << ' ';
for (std::size_t column = 0; column < widths.size(); ++column) { for (std::size_t column = 0; column < widths.size(); ++column) {
const auto name_index = weekday_name_index(state.week_start, column); const auto name_index = weekday_name_index(state.week_start, column);
const auto name = widths[column] >= 9 ? weekday_long[name_index] const auto name = widths[column] >= 9 ? weekday_long[name_index]
: weekday_short[name_index]; : weekday_short[name_index];
output << styled(centred(name, widths[column]), "2;1", state.ansi); output << styled(centred(name, widths[column]), "2", state.ansi);
output << (column + 1 == widths.size() ? ' ' : ' '); output << (column + 1 == widths.size() ? ' ' : ' ');
} }
output << '\n' << border(widths, "", "", "") << '\n'; output << '\n' << border(widths, "", "", "") << '\n';
@@ -956,26 +1187,54 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
const auto selected_count = by_day.contains(sys_days{state.selected_day}) const auto selected_count = by_day.contains(sys_days{state.selected_day})
? by_day.at(sys_days{state.selected_day}).size() : 0U; ? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) { // Status left, priority-ordered key hints right.
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read g agenda c calendars / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit"; const auto friendly_day = [](const year_month_day value) {
} else if (!state.notification.empty()) { return std::string{weekday_short[weekday{sys_days{value}}.iso_encoding() - 1]} + ", " +
status = " " + printable_text(state.notification, true); std::to_string(static_cast<unsigned>(value.day())) + " " +
std::string{month_short[static_cast<unsigned>(value.month()) - 1]} + " " +
std::to_string(static_cast<int>(value.year()));
};
std::vector<KeyHint> hints;
std::string left_status;
std::string status_style;
if (!state.notification.empty()) {
left_status = (state.notification_is_error ? "! " : "") +
printable_text(state.notification, true);
status_style = state.notification_is_error ? "1;31" : "1;32";
} else if (focused != nullptr) { } else if (focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"} const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title); : printable_text(focused->title);
status = " " + iso_date(state.selected_day) + " " + left_status = friendly_day(state.selected_day) + " · " + focus_title;
focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo"; hints = {{
{"Tab", "next"},
{"Enter", "read"},
{"e", "edit"},
{"d", "del"},
{"u", "undo"},
{"?", "help"},
}};
} else { } else {
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) + left_status = friendly_day(state.selected_day) + " · " +
(selected_count == 1 ? " appointment" : " appointments") + std::to_string(selected_count) +
(selected_count > 0 ? " Tab focus" : "") + (selected_count == 1 ? " appointment" : " appointments");
" g agenda c calendars / search a add u undo Ctrl-R redo ? help q quit"; if (selected_count > 0) {
hints = {{"Tab", "focus"}};
}
const std::array right_hints_no_focus{
KeyHint{"a", "add"},
KeyHint{"g", "agenda"},
KeyHint{"c", "calendars"},
KeyHint{"/", "search"},
KeyHint{"u", "undo"},
KeyHint{"?", "help"},
KeyHint{"q", "quit"},
};
hints.insert(hints.end(), right_hints_no_focus.begin(), right_hints_no_focus.end());
} }
const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32") output << status_bar(left_status, status_style, hints, width, state.ansi);
: focused != nullptr ? "7" : "2";
output << styled(fit_text(status, width), status_style, state.ansi);
return output.str(); return output.str();
} }

View File

@@ -1,6 +1,7 @@
#include "nocal/tui/TuiApp.hpp" #include "nocal/tui/TuiApp.hpp"
#include "nocal/domain/event_query.hpp" #include "nocal/domain/event_query.hpp"
#include "nocal/sync/token_broker.hpp"
#include "nocal/tui/Terminal.hpp" #include "nocal/tui/Terminal.hpp"
#include <algorithm> #include <algorithm>
@@ -10,7 +11,9 @@
#include <cstdlib> #include <cstdlib>
#include <ctime> #include <ctime>
#include <exception> #include <exception>
#include <mutex>
#include <string> #include <string>
#include <thread>
#include <utility> #include <utility>
#include <poll.h> #include <poll.h>
@@ -101,6 +104,78 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver,
synchronize_calendars(); synchronize_calendars();
} }
void TuiApp::on_sync_progress(const sync::SyncProgressEvent& event)
{
std::string stage_text;
switch (event.stage) {
case sync::SyncStage::authenticating:
stage_text = "Authenticating...";
break;
case sync::SyncStage::refreshing_identity:
stage_text = "Refreshing identity...";
break;
case sync::SyncStage::discovering_calendars:
stage_text = "Discovering calendars...";
break;
case sync::SyncStage::syncing_calendar:
stage_text = "Syncing " + event.calendar_name + "...";
break;
case sync::SyncStage::completed:
stage_text = "Sync complete.";
break;
}
apply_sync_stage(stage_text, false);
}
void TuiApp::apply_sync_stage(const std::string& stage_text, const bool is_error)
{
std::lock_guard<std::mutex> lock(sync_mutex_);
state_.sync_stage = stage_text;
state_.sync_is_error = is_error;
state_.notification = stage_text;
state_.notification_is_error = is_error;
}
void TuiApp::clear_sync_state()
{
std::lock_guard<std::mutex> lock(sync_mutex_);
state_.sync_stage.clear();
state_.sync_is_error = false;
if (!state_.sync_stage.empty() || state_.notification.empty()) {
state_.notification.clear();
state_.notification_is_error = false;
}
}
void TuiApp::start_sync(const std::string_view account_id)
{
if (sync_thread_ && sync_thread_->joinable()) {
return;
}
if (!sync_provider_ || account_id.empty()) {
return;
}
connected_account_id_ = std::string{account_id};
apply_sync_stage("Syncing...", false);
sync_thread_.emplace([this, acct = std::string{account_id}]() {
try {
sync_provider_->sync_account(acct, *this);
} catch (const sync::AccountDisconnected& e) {
apply_sync_stage("Account disconnected. Run: nocal account connect microsoft", true);
} catch (const std::exception& e) {
apply_sync_stage("Sync failed: " + std::string{e.what()}, true);
}
});
}
void TuiApp::stop_sync()
{
if (sync_thread_ && sync_thread_->joinable()) {
sync_thread_->join();
sync_thread_.reset();
}
}
std::optional<EventOccurrence> TuiApp::focused_occurrence() const { std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
if (!state_.focused_event_id) return std::nullopt; if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_}; const auto event_span = std::span<const Event>{events_};
@@ -200,6 +275,36 @@ void TuiApp::close_agenda() {
state_.show_agenda = false; state_.show_agenda = false;
} }
void TuiApp::begin_linear_view() {
state_.show_linear_view = true;
state_.agenda_start_day = state_.selected_day;
state_.agenda_index = 0;
state_.agenda_items.clear();
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
state_.search_prompt = false;
state_.show_search_results = false;
state_.show_calendar_picker = false;
state_.show_agenda = false;
refresh_agenda();
// Place cursor on the first item on or after the selected day.
if (!state_.agenda_items.empty()) {
const auto target = sys_days{state_.selected_day};
for (std::size_t index = 0; index < state_.agenda_items.size(); ++index) {
if (sys_days{state_.agenda_items[index].day} >= target) {
state_.agenda_index = index;
return;
}
}
state_.agenda_index = 0;
}
}
void TuiApp::close_linear_view() {
state_.show_linear_view = false;
}
void TuiApp::refresh_agenda() { void TuiApp::refresh_agenda() {
if (!state_.agenda_start_day.ok()) state_.agenda_start_day = state_.selected_day; if (!state_.agenda_start_day.ok()) state_.agenda_start_day = state_.selected_day;
std::optional<std::string> selected_identity; std::optional<std::string> selected_identity;
@@ -286,6 +391,16 @@ void TuiApp::choose_agenda_item() {
close_agenda(); close_agenda();
} }
void TuiApp::choose_linear_item() {
if (state_.agenda_index >= state_.agenda_items.size()) return;
const auto& item = state_.agenda_items[state_.agenda_index];
state_.selected_day = item.day;
state_.visible_month = item.day.year() / item.day.month();
state_.focused_event_id = item.focus_id;
state_.show_event_details = false;
close_linear_view();
}
void TuiApp::set_notification(std::string message, const bool error) { void TuiApp::set_notification(std::string message, const bool error) {
state_.notification = std::move(message); state_.notification = std::move(message);
state_.notification_is_error = error; state_.notification_is_error = error;
@@ -733,6 +848,8 @@ void TuiApp::dispatch(const Action action) {
case Action::redo: case Action::redo:
case Action::agenda: case Action::agenda:
case Action::calendars: case Action::calendars:
case Action::linear_view:
case Action::sync:
return; return;
} }
} }
@@ -774,6 +891,62 @@ void TuiApp::dispatch(const Action action) {
case Action::redo: case Action::redo:
case Action::search: case Action::search:
case Action::agenda: case Action::agenda:
case Action::linear_view:
case Action::sync:
return;
}
}
if (state_.show_linear_view) {
const auto count = state_.agenda_items.size();
switch (action) {
case Action::up:
case Action::left:
case Action::previous_event:
if (count > 0 && state_.agenda_index > 0) --state_.agenda_index;
return;
case Action::down:
case Action::right:
case Action::next_event:
if (count > 0 && state_.agenda_index + 1 < count) ++state_.agenda_index;
return;
case Action::previous_month:
shift_agenda(-1);
return;
case Action::next_month:
shift_agenda(1);
return;
case Action::today:
state_.today = current_day();
state_.agenda_start_day = state_.today;
state_.agenda_items.clear();
state_.agenda_index = 0;
refresh_agenda();
return;
case Action::select:
choose_linear_item();
return;
case Action::back:
case Action::linear_view:
close_linear_view();
return;
case Action::search:
begin_search();
return;
case Action::calendars:
begin_calendar_picker();
return;
case Action::quit:
running_ = false;
return;
case Action::none:
case Action::toggle_help:
case Action::add_event:
case Action::edit_event:
case Action::delete_event:
case Action::undo:
case Action::redo:
case Action::agenda:
case Action::sync:
return; return;
} }
} }
@@ -826,6 +999,8 @@ void TuiApp::dispatch(const Action action) {
case Action::delete_event: case Action::delete_event:
case Action::undo: case Action::undo:
case Action::redo: case Action::redo:
case Action::linear_view:
case Action::sync:
return; return;
} }
} }
@@ -911,6 +1086,8 @@ void TuiApp::dispatch(const Action action) {
case Action::next_month: case Action::next_month:
case Action::today: case Action::today:
case Action::toggle_help: case Action::toggle_help:
case Action::linear_view:
case Action::sync:
return; return;
} }
} }
@@ -996,9 +1173,19 @@ void TuiApp::dispatch(const Action action) {
case Action::agenda: case Action::agenda:
begin_agenda(); begin_agenda();
return; return;
case Action::linear_view:
begin_linear_view();
return;
case Action::calendars: case Action::calendars:
begin_calendar_picker(); begin_calendar_picker();
return; return;
case Action::sync:
if (connected_account_id_) {
start_sync(*connected_account_id_);
} else {
set_notification("No connected account. Run: nocal account connect microsoft", true);
}
return;
case Action::quit: case Action::quit:
running_ = false; running_ = false;
return; return;
@@ -1073,6 +1260,7 @@ ExitReason TuiApp::run() {
redraw = true; redraw = true;
} }
} }
stop_sync();
return ExitReason::quit; return ExitReason::quit;
} }

270
src/tui/unicode.cpp Normal file
View File

@@ -0,0 +1,270 @@
#include "nocal/tui/unicode.hpp"
#include <algorithm>
#include <cstdint>
#include <string>
#include <string_view>
namespace nocal::tui {
namespace {
// Check if a codepoint is a skin tone modifier (0x1f3fb0x1f3ff)
[[nodiscard]] bool is_skin_tone_modifier(std::uint32_t cp) noexcept
{
return cp >= 0x1f3fbU && cp <= 0x1f3ffU;
}
// Check if a codepoint is a hair style modifier (0x1f9b00x1f9b3, 0x1f9b6)
[[nodiscard]] bool is_hair_modifier(std::uint32_t cp) noexcept
{
return (cp >= 0x1f9b0U && cp <= 0x1f9b3U) || cp == 0x1f9b6U;
}
// Check if a codepoint is a regional indicator (0x1f1e60x1f1ff)
[[nodiscard]] bool is_regional_indicator(std::uint32_t cp) noexcept
{
return cp >= 0x1f1e6U && cp <= 0x1f1ffU;
}
// Check if a codepoint is a variation selector (0xfe000xfe0f)
[[nodiscard]] bool is_variation_selector(std::uint32_t cp) noexcept
{
return cp >= 0xfe00U && cp <= 0xfe0fU;
}
// Check if a codepoint is an emoji modifier that should be absorbed into a
// preceding emoji base character (skin tone or hair modifier).
[[nodiscard]] bool is_emoji_modifier(std::uint32_t cp) noexcept
{
return is_skin_tone_modifier(cp) || is_hair_modifier(cp);
}
// Decode one grapheme cluster starting at position `at` in `text`.
// Advances `at` past the entire cluster and returns the cluster's display width.
// Assumes at < text.size() on entry.
[[nodiscard]] int next_cluster_width(std::string_view text, std::size_t& at)
{
const auto first = decode_codepoint(text, at);
const auto base = first.value;
std::size_t cluster_end = at + first.length;
// Regional indicator pair: two in sequence form one grapheme of width 2
if (is_regional_indicator(base)) {
if (cluster_end < text.size()) {
const auto next = decode_codepoint(text, cluster_end);
if (is_regional_indicator(next.value)) {
at = cluster_end + next.length;
return 2;
}
}
// Standalone regional indicator (shouldn't happen in well-formed text)
at = cluster_end;
return 1;
}
// ZWJ at start of cluster: absorb the ZWJ and following character(s)
if (base == 0x200dU) {
// Look ahead for the joined character
if (cluster_end < text.size()) {
const auto joined = decode_codepoint(text, cluster_end);
cluster_end += joined.length;
// Absorb any trailing modifiers
while (cluster_end < text.size()) {
const auto mod = decode_codepoint(text, cluster_end);
if (is_emoji_modifier(mod.value)) {
cluster_end += mod.length;
} else {
break;
}
}
}
at = cluster_end;
return 2;
}
// Determine the base width
const int base_width = codepoint_width(base);
// Check if base is an emoji (width-2 char in emoji range)
const bool is_emoji_base = (base_width == 2 && base >= 0x2600U);
// Absorb any combining marks, variation selectors, ZWJ sequences, and
// emoji modifiers that follow the base character.
bool found_zwj = false;
while (cluster_end < text.size()) {
const auto next = decode_codepoint(text, cluster_end);
const auto next_cp = next.value;
if (next_cp == 0x200dU) {
// ZWJ: absorbed, but check what follows
found_zwj = true;
cluster_end += next.length;
// The next codepoint after ZWJ is part of the sequence
if (cluster_end < text.size()) {
const auto after_zwj = decode_codepoint(text, cluster_end);
cluster_end += after_zwj.length;
// Absorb any modifiers after the ZWJ-joined char
while (cluster_end < text.size()) {
const auto mod = decode_codepoint(text, cluster_end);
if (is_emoji_modifier(mod.value)) {
cluster_end += mod.length;
} else {
break;
}
}
}
} else if (is_variation_selector(next_cp) || codepoint_width(next_cp) == 0) {
// Variation selector or combining mark: absorbed
cluster_end += next.length;
} else if (is_emoji_modifier(next_cp)) {
// Emoji modifier: only absorbed if preceded by emoji base or ZWJ
if (is_emoji_base || found_zwj) {
cluster_end += next.length;
} else {
break;
}
} else if (next_cp == 0x200dU) {
// ZWJ: absorbed, but check what follows
found_zwj = true;
cluster_end += next.length;
// The next codepoint after ZWJ is part of the sequence
if (cluster_end < text.size()) {
const auto after_zwj = decode_codepoint(text, cluster_end);
cluster_end += after_zwj.length;
// Absorb any modifiers after the ZWJ-joined char
while (cluster_end < text.size()) {
const auto mod = decode_codepoint(text, cluster_end);
if (is_emoji_modifier(mod.value)) {
cluster_end += mod.length;
} else {
break;
}
}
}
} else {
break;
}
}
// Final cluster width: base width (ZWJ sequences are always width 2, otherwise
// it's the base codepoint width)
at = cluster_end;
return found_zwj ? 2 : base_width;
}
} // anonymous namespace
DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept
{
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80U) return {first, 1};
std::uint32_t value = 0;
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) {
value = first & 0x1fU;
length = 2;
} else if ((first & 0xf0U) == 0xe0U) {
value = first & 0x0fU;
length = 3;
} else if ((first & 0xf8U) == 0xf0U) {
value = first & 0x07U;
length = 4;
} else {
return {0xfffdU, 1};
}
if (at + length > text.size()) return {0xfffdU, 1};
for (std::size_t offset = 1; offset < length; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + offset]);
if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1};
value = (value << 6U) | (byte & 0x3fU);
}
return {value, length};
}
int codepoint_width(const std::uint32_t value) noexcept
{
// Zero-width / combining marks
if ((value >= 0x0300U && value <= 0x036fU) ||
(value >= 0x1ab0U && value <= 0x1affU) ||
(value >= 0x1dc0U && value <= 0x1dffU) ||
(value >= 0x20d0U && value <= 0x20ffU) ||
(value >= 0xfe00U && value <= 0xfe0fU) ||
(value >= 0xfe20U && value <= 0xfe2fU) ||
value == 0x200dU ||
// Variation selectors supplement (Plane 14)
(value >= 0xe0100U && value <= 0xe01efU)) {
return 0;
}
// Double-width codepoints
if ((value >= 0x1100U && value <= 0x115fU) ||
value == 0x2329U || value == 0x232aU ||
(value >= 0x2e80U && value <= 0xa4cfU) ||
(value >= 0xac00U && value <= 0xd7a3U) ||
(value >= 0xf900U && value <= 0xfaffU) ||
(value >= 0xfe10U && value <= 0xfe19U) ||
(value >= 0xfe30U && value <= 0xfe6fU) ||
(value >= 0xff00U && value <= 0xff60U) ||
(value >= 0xffe0U && value <= 0xffe6U) ||
(value >= 0x2600U && value <= 0x27bfU) ||
(value >= 0x1f300U && value <= 0x1faffU) ||
(value >= 0x20000U && value <= 0x3fffdU)) {
return 2;
}
return 1;
}
int display_width(const std::string_view text)
{
int width = 0;
std::size_t at = 0;
while (at < text.size()) {
width += next_cluster_width(text, at);
}
return width;
}
std::string fit_text(const std::string_view text, const int requested_width, const bool ellipsis)
{
const int width = std::max(0, requested_width);
if (width == 0) {
return {};
}
// If text fits, pad and return
if (display_width(text) <= width) {
std::string result{text};
const auto remaining = static_cast<std::size_t>(width - display_width(text));
if (remaining > 0) {
result.append(remaining, ' ');
}
return result;
}
const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0));
std::string result;
int used = 0;
std::size_t at = 0;
while (at < text.size()) {
std::size_t cluster_start = at;
const int cluster_width = next_cluster_width(text, at);
if (used + cluster_width > content_limit) {
break;
}
result.append(text.substr(cluster_start, at - cluster_start));
used += cluster_width;
}
if (ellipsis && width > 1) {
result += "\u2026";
++used;
}
if (const auto remaining = static_cast<std::size_t>(std::max(0, width - used)); remaining > 0) {
result.append(remaining, ' ');
}
return result;
}
} // namespace nocal::tui

206
tests/caldav_xml_tests.cpp Normal file
View File

@@ -0,0 +1,206 @@
#include "nocal/sync/caldav_xml.hpp"
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
namespace {
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string(message));
}
}
void test_simple_element() {
const auto root = nocal::sync::parse_xml("<root><child>text</child></root>");
require(root.tag == "root", "root tag");
require(root.children.size() == 1, "one child");
require(root.children[0].tag == "child", "child tag");
require(root.children[0].text == "text", "child text");
}
void test_namespace_prefix() {
const auto root = nocal::sync::parse_xml("<D:propfind><D:prop><C:displayname/></D:prop></D:propfind>");
require(root.tag == "D:propfind", "namespaced root");
require(root.children[0].tag == "D:prop", "namespaced child");
require(root.children[0].children[0].tag == "C:displayname", "nested namespaced");
}
void test_attributes() {
const auto root = nocal::sync::parse_xml(R"(<elem href="/path" name="test" />)");
require(root.tag == "elem", "tag");
require(root.attributes.size() == 2, "two attributes");
require(nocal::sync::get_attribute(root, "href") == "/path", "href");
require(nocal::sync::get_attribute(root, "name") == "test", "name");
}
void test_cdata() {
const auto root = nocal::sync::parse_xml("<data><![CDATA[<xml>content</xml>]]></data>");
require(root.cdata == "<xml>content</xml>", "CDATA content preserved");
}
void test_self_closing() {
const auto root = nocal::sync::parse_xml("<root><a/><b /></root>");
require(root.children.size() == 2, "two self-closing children");
require(root.children[0].children.empty(), "no nested children in self-closing");
}
void test_nested_elements() {
const auto root = nocal::sync::parse_xml("<a><b><c>deep</c></b></a>");
require(root.children[0].children[0].text == "deep", "deep nesting");
}
void test_entity_references() {
const auto root = nocal::sync::parse_xml("<text>&lt;hello&gt; &amp; world</text>");
require(root.text == "<hello> & world", "entities replaced");
}
void test_processing_instruction() {
const auto root = nocal::sync::parse_xml("<?xml version=\"1.0\"?><doc/>");
require(root.tag == "doc", "PI skipped, root found");
}
void test_comment_skipped() {
const auto root = nocal::sync::parse_xml("<!-- comment --><doc/>");
require(root.tag == "doc", "comment skipped");
}
void test_find_children() {
const auto root = nocal::sync::parse_xml("<root><a>1</a><b>2</b><a>3</a></root>");
const auto found = nocal::sync::find_children(root, "a");
require(found.size() == 2, "two 'a' children");
require(found[0]->text == "1", "first a");
require(found[1]->text == "3", "second a");
}
void test_get_child_text() {
const auto root = nocal::sync::parse_xml("<root><name>Bob</name></root>");
require(nocal::sync::get_child_text(root, "name") == "Bob", "child text");
require(nocal::sync::get_child_text(root, "missing").empty(), "missing child");
}
void test_empty_document() {
bool threw = false;
try {
(void)nocal::sync::parse_xml("");
} catch (const std::runtime_error&) {
threw = true;
}
require(threw, "empty document throws");
}
void test_unclosed_tag() {
bool threw = false;
try {
(void)nocal::sync::parse_xml("<root><child>");
} catch (const std::runtime_error&) {
threw = true;
}
require(threw, "unclosed tag throws");
}
void test_mismatched_tag() {
bool threw = false;
try {
(void)nocal::sync::parse_xml("<root><child></wrong>");
} catch (const std::runtime_error&) {
threw = true;
}
require(threw, "mismatched tag throws");
}
void test_max_depth() {
bool threw = false;
std::string deep("<a>");
for (int i = 0; i < 100; ++i) {
deep += "<a>";
}
deep += "x";
for (int i = 0; i < 100; ++i) {
deep += "</a>";
}
deep += "</a>";
try {
(void)nocal::sync::parse_xml(deep);
} catch (const std::runtime_error&) {
threw = true;
}
require(threw, "excessive depth throws");
}
void test_caldav_propfind_response() {
const std::string doc = R"(<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="CALDAV:" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/calendars/user/calendar1/</D:href>
<D:propstat>
<D:status>HTTP/1.1 200 OK</D:status>
<D:prop>
<D:displayname>My Calendar</D:displayname>
<D:resourcetype><D:collection><C:calendar/></D:collection></D:resourcetype>
<D:sync-token>sync-token-123</D:sync-token>
</D:prop>
</D:propstat>
</D:response>
</D:multistatus>)";
const auto root = nocal::sync::parse_xml(doc);
require(root.tag == "D:multistatus", "root tag");
require(root.children.size() == 1, "one response");
const auto& response = root.children[0];
require(response.tag == "D:response", "response element");
const auto hrefs = nocal::sync::find_children(response, "D:href");
require(hrefs.size() == 1 && hrefs[0]->text == "/calendars/user/calendar1/", "href");
// D:displayname is inside D:propstat > D:prop, so traverse down
const auto& propstat = response.children[1];
require(propstat.tag == "D:propstat", "propstat element");
const auto& prop = propstat.children[1];
require(prop.tag == "D:prop", "prop element");
const auto displaynames = nocal::sync::find_children(prop, "D:displayname");
require(displaynames.size() == 1 && displaynames[0]->text == "My Calendar", "displayname");
const auto sync_tokens = nocal::sync::find_children(prop, "D:sync-token");
require(sync_tokens.size() == 1 && sync_tokens[0]->text == "sync-token-123", "sync-token");
}
void test_max_size() {
bool threw = false;
try {
const std::string large(5 * 1024 * 1024, 'x');
(void)nocal::sync::parse_xml("<root>" + large + "</root>", 1024);
} catch (const std::runtime_error&) {
threw = true;
}
require(threw, "oversized document throws");
}
} // namespace
int main() {
try {
test_simple_element();
test_namespace_prefix();
test_attributes();
test_cdata();
test_self_closing();
test_nested_elements();
test_entity_references();
test_processing_instruction();
test_comment_skipped();
test_find_children();
test_get_child_text();
test_empty_document();
test_unclosed_tag();
test_mismatched_tag();
test_max_depth();
test_caldav_propfind_response();
test_max_size();
std::cout << "caldav_xml_tests: ok\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "caldav_xml_tests: " << error.what() << '\n';
return 1;
}
}

View File

@@ -61,7 +61,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
recurrence.interval = 2; recurrence.interval = 2;
recurrence.count = 6; recurrence.count = 6;
recurrence.until = at(24); recurrence.until = at(24);
recurrence.by_weekday = {Monday, Wednesday}; recurrence.by_weekday = {{0, Monday}, {0, Wednesday}};
recurrence.by_month_day = {1, -1}; recurrence.by_month_day = {1, -1};
recurrence.by_month = {1, 7}; recurrence.by_month = {1, 7};
event.recurrence = std::move(recurrence); event.recurrence = std::move(recurrence);

View File

@@ -384,7 +384,7 @@ void test_weekly_count_and_exdates()
nocal::RecurrenceRule rule; nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::weekly; rule.frequency = nocal::RecurrenceFrequency::weekly;
rule.count = 6U; rule.count = 6U;
rule.by_weekday = {Monday, Wednesday, Friday}; rule.by_weekday = {{0, Monday}, {0, Wednesday}, {0, Friday}};
auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule); auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule);
weekly.recurrence_exceptions = {weekly.start, weekly.recurrence_exceptions = {weekly.start,
utc_time(2026y / July / 10d, 9)}; utc_time(2026y / July / 10d, 9)};

View File

@@ -47,6 +47,10 @@ void test_create_and_validation()
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(), editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
"blank title blocks submission and focuses the invalid field"); "blank title blocks submission and focuses the invalid field");
check(editor.render(72, 18, true).find("\x1b[1;31m") != std::string::npos,
"validation errors render in the error style");
check(editor.render(72, 18, false).find("\x1b[") == std::string::npos,
"error frame stays plain with ANSI disabled");
(void)editor.handle_key("Design review"); (void)editor.handle_key("Design review");
focus(editor, nocal::tui::EditorField::end_time); focus(editor, nocal::tui::EditorField::end_time);
@@ -179,6 +183,8 @@ void test_rendering()
"render fills requested height exactly"); "render fills requested height exactly");
check(plain.find("NEW APPOINTMENT") != std::string::npos && check(plain.find("NEW APPOINTMENT") != std::string::npos &&
plain.find("Ctrl-S save") != std::string::npos && plain.find("Ctrl-S save") != std::string::npos &&
plain.find("Tab next field") != std::string::npos &&
plain.find("Esc cancel") != std::string::npos &&
plain.find("[ ] timed appointment") != std::string::npos, plain.find("[ ] timed appointment") != std::string::npos,
"render exposes form identity and controls"); "render exposes form identity and controls");
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled"); check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
@@ -309,6 +315,182 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
"a nonexistent source-zone civil time in the DST gap is rejected"); "a nonexistent source-zone civil time in the DST gap is rejected");
} }
void test_recurrence_frequency_cycling()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Weekly meeting");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
// Default is "none"
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
"new event starts with no recurrence");
// Space cycles through frequencies
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "daily",
"Space cycles to daily");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
"Space cycles to weekly");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "monthly",
"Space cycles to monthly");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "yearly",
"Space cycles to yearly");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
"Space wraps around to none");
}
void test_recurrence_submit()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
// Test: daily recurrence with count
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Daily standup");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
focus(editor, nocal::tui::EditorField::recurrence_count);
replace_field(editor, "5");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"daily with count=5 submits");
check(editor.result().recurrence.has_value(), "recurrence should be set");
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::daily,
"frequency is daily");
check(editor.result().recurrence->interval == 1, "default interval is 1");
check(editor.result().recurrence->count == 5, "count is 5");
}
// Test: weekly recurrence (no by_weekday -> fills with DTSTART's weekday)
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Weekly sync");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
(void)editor.handle_key(" "); // -> weekly
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"weekly without BYDAY submits");
check(editor.result().recurrence.has_value(), "recurrence should be set");
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::weekly,
"frequency is weekly");
check(!editor.result().recurrence->by_weekday.empty(),
"weekly without BYDAY should auto-fill with start weekday");
// July 17, 2026 is a Friday
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Friday,
"auto-filled weekday should be Friday for 2026-07-17");
}
// Test: no recurrence (none)
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("One-off meeting");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"event with frequency=none submits");
check(!editor.result().recurrence.has_value(),
"frequency=none should clear recurrence");
}
// Test: invalid interval
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Bad interval");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
focus(editor, nocal::tui::EditorField::recurrence_interval);
replace_field(editor, "0");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.error().find("Interval") != std::string_view::npos,
"interval 0 is rejected");
}
}
void test_edit_recurring_event_populates_fields()
{
using namespace std::chrono;
nocal::Event event;
event.uid = "recurring-edit-test";
event.title = "Original recurring";
event.start = sys_days{year{2026}/July/17} + 9h;
event.end = sys_days{year{2026}/July/17} + 10h;
event.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::weekly,
.interval = 2,
.count = 10,
.until = std::nullopt,
.by_weekday = {{0, std::chrono::Monday}, {0, std::chrono::Wednesday}, {0, std::chrono::Friday}},
.by_month_day = {},
.by_month = {},
};
nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
"edit populates recurrence frequency from existing rule");
check(editor.field_value(nocal::tui::EditorField::recurrence_interval) == "2",
"edit populates recurrence interval from existing rule");
check(editor.field_value(nocal::tui::EditorField::recurrence_count) == "10",
"edit populates recurrence count from existing rule");
check(editor.field_value(nocal::tui::EditorField::recurrence_by_weekday) == "MO,WE,FR",
"edit populates BYDAY from existing rule");
}
void test_rendering_shows_recurrence()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
const auto plain = editor.render(72, 22, false);
check(plain.find("Repeat") != std::string::npos,
"render shows Recurrence label");
check(plain.find("none") != std::string::npos,
"render shows default frequency as none");
// Cycle to weekly and check render
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
(void)editor.handle_key(" "); // -> weekly
const auto with_recurrence = editor.render(72, 22, false);
check(with_recurrence.find("weekly") != std::string::npos,
"render shows weekly when set");
check(with_recurrence.find("Interval") != std::string::npos,
"render shows Interval when frequency is set");
check(with_recurrence.find("Count") != std::string::npos,
"render shows Count when frequency is set");
check(with_recurrence.find("On days") != std::string::npos,
"render shows On days when frequency is set");
}
void test_recurrence_weekly_by_weekday_input()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Custom weekly");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
(void)editor.handle_key(" "); // -> weekly
focus(editor, nocal::tui::EditorField::recurrence_by_weekday);
replace_field(editor, "MO,WE,FR");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"custom weekly with MO,WE,FR submits");
check(editor.result().recurrence.has_value(), "recurrence should be set");
check(editor.result().recurrence->by_weekday.size() == 3,
"three BYDAY entries expected");
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Monday,
"first BYDAY is Monday");
check(editor.result().recurrence->by_weekday[1].day == std::chrono::Wednesday,
"second BYDAY is Wednesday");
check(editor.result().recurrence->by_weekday[2].day == std::chrono::Friday,
"third BYDAY is Friday");
}
} // namespace } // namespace
int main() int main()
@@ -320,6 +502,11 @@ int main()
test_unicode_backspace_navigation_and_cancel(); test_unicode_backspace_navigation_and_cancel();
test_rendering(); test_rendering();
test_zoned_edit_preserves_series_and_rejects_dst_gap(); test_zoned_edit_preserves_series_and_rejects_dst_gap();
test_recurrence_frequency_cycling();
test_recurrence_submit();
test_edit_recurring_event_populates_fields();
test_rendering_shows_recurrence();
test_recurrence_weekly_by_weekday_input();
if (failures != 0) { if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n"; std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -512,7 +512,7 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
"multiple/comma-separated EXDATE values were not collected"); "multiple/comma-separated EXDATE values were not collected");
const auto& weekly = *loaded.events[1].recurrence; const auto& weekly = *loaded.events[1].recurrence;
require(weekly.frequency == nocal::RecurrenceFrequency::weekly require(weekly.frequency == nocal::RecurrenceFrequency::weekly
&& weekly.by_weekday == std::vector<weekday>{Monday, Wednesday, Friday} && weekly.by_weekday == std::vector<nocal::ByWeekday>{{0, Monday}, {0, Wednesday}, {0, Friday}}
&& weekly.until.has_value(), && weekly.until.has_value(),
"weekly RRULE fields differ"); "weekly RRULE fields differ");
const auto& monthly = *loaded.events[2].recurrence; const auto& monthly = *loaded.events[2].recurrence;
@@ -652,15 +652,99 @@ void test_supported_rdates(const std::filesystem::path& root) {
} }
} }
void test_ordinal_byday_round_trip(const std::filesystem::path& root) {
const auto path = root / "ordinal-byday.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:ordinal-weekly\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\n"
"RRULE:FREQ=MONTHLY;BYDAY=+1MO,-1FR\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"ordinal BYDAY should be rewrite-safe");
require(loaded.events.size() == 1, "ordinal BYDAY fixture event count differs");
const auto& rule = *loaded.events[0].recurrence;
require(rule.frequency == nocal::RecurrenceFrequency::monthly,
"monthly frequency should be preserved");
require(rule.by_weekday.size() == 2, "two BYDAY entries expected");
require(rule.by_weekday[0].ordinal == 1 && rule.by_weekday[0].day == std::chrono::Monday,
"first BYDAY should be +1MO");
require(rule.by_weekday[1].ordinal == -1 && rule.by_weekday[1].day == std::chrono::Friday,
"second BYDAY should be -1FR");
const auto round_trip = root / "ordinal-byday-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("BYDAY=+1MO,-1FR") != std::string::npos,
"ordinal BYDAY should serialize as +1MO,-1FR");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"ordinal BYDAY round-trip should be rewrite-safe");
require(reloaded.events[0].recurrence == loaded.events[0].recurrence,
"ordinal BYDAY semantic round trip differs");
}
void test_daily_with_byday_is_supported(const std::filesystem::path& root) {
const auto path = root / "daily-byday.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:daily-byday\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\n"
"RRULE:FREQ=DAILY;BYDAY=MO,WE,FR\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"DAILY with BYDAY should now be rewrite-safe");
require(loaded.events.size() == 1, "daily-byday fixture event count differs");
const auto& rule = *loaded.events[0].recurrence;
require(rule.frequency == nocal::RecurrenceFrequency::daily,
"daily frequency should be preserved");
require(rule.by_weekday.size() == 3, "three BYDAY entries expected");
}
void test_recurrence_id_round_trip(const std::filesystem::path& root) {
const auto path = root / "recurrence-id.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:series-1@example.test\r\n"
"DTSTART:20260701T090000Z\r\nDTEND:20260701T100000Z\r\n"
"RRULE:FREQ=DAILY;COUNT=5\r\n"
"SUMMARY:Series\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:series-1@example.test\r\n"
"RECURRENCE-ID:20260703T090000Z\r\n"
"DTSTART:20260703T110000Z\r\nDTEND:20260703T120000Z\r\n"
"SUMMARY:Moved occurrence\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"RECURRENCE-ID should be rewrite-safe");
require(loaded.events.size() == 2, "should parse series and override");
require(loaded.events[0].recurrence.has_value(), "series should have RRULE");
require(loaded.events[1].recurrence_id.has_value(), "override should have recurrence_id");
require(!loaded.events[1].recurrence.has_value(), "override should not have RRULE");
const auto round_trip = root / "recurrence-id-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("RECURRENCE-ID:20260703T090000Z") != std::string::npos,
"RECURRENCE-ID should be serialized");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.events.size() == 2,
"RECURRENCE-ID round-trip should be rewrite-safe");
require(reloaded.events[1].recurrence_id.has_value(),
"round-trip override should retain recurrence_id");
}
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) { void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
const std::vector<std::string> unsupported_properties{ const std::vector<std::string> unsupported_properties{
"RRULE:FREQ=WEEKLY;BYDAY=1MO",
"RRULE:FREQ=DAILY;BYDAY=MO",
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z", "RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
"RRULE:FREQ=HOURLY", "RRULE:FREQ=HOURLY",
"RRULE:FREQ=DAILY;WKST=MO", "RRULE:FREQ=DAILY;WKST=MO",
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H", "RDATE;VALUE=PERIOD:20260718T090000Z/PT1H"};
"RECURRENCE-ID:20260718T090000Z"};
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) { for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics"); const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
write_fixture(path, write_fixture(path,
@@ -697,8 +781,14 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
"END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n" "END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"); "END:VEVENT\r\nEND:VCALENDAR\r\n");
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite, // VTIMEZONE is now parsed and preserved for round-trip, so the calendar
"VTIMEZONE definition was considered rewrite-safe"); // is safe to rewrite even though the custom TZID is unused by the event.
const auto vtz_result = nocal::storage::IcsStore::load(custom_zone);
require(vtz_result.safe_to_rewrite,
"VTIMEZONE definition should be safe when round-trippable");
require(vtz_result.vtimezones.size() == 1 &&
vtz_result.vtimezones[0].tzid == "Custom/Test",
"VTIMEZONE should be captured even when no event references it");
struct InvalidRdate { struct InvalidRdate {
std::string start; std::string start;
@@ -773,7 +863,7 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule"); nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
nocal::RecurrenceRule rule; nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::daily; rule.frequency = nocal::RecurrenceFrequency::daily;
rule.by_weekday.push_back(Monday); rule.interval = 0;
invalid_rule.recurrence = rule; invalid_rule.recurrence = rule;
bool rule_failed = false; bool rule_failed = false;
try { try {
@@ -813,6 +903,131 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
require_no_temporary_files(path); require_no_temporary_files(path);
} }
void test_vtimezone_custom_tzid_safe_and_round_trips(const std::filesystem::path& root) {
const auto path = root / "vtimezone-custom.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\n"
"TZID:Custom/Example\r\n"
"BEGIN:STANDARD\r\n"
"TZOFFSETFROM:+0100\r\n"
"TZOFFSETTO:+0000\r\n"
"DTSTART:19701025T030000\r\n"
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n"
"END:STANDARD\r\n"
"BEGIN:DAYLIGHT\r\n"
"TZOFFSETFROM:+0000\r\n"
"TZOFFSETTO:+0100\r\n"
"DTSTART:19700329T020000\r\n"
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n"
"END:DAYLIGHT\r\n"
"END:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\n"
"UID:custom-tz\r\n"
"DTSTART;TZID=Custom/Example:20260717T100000\r\n"
"DTEND;TZID=Custom/Example:20260717T110000\r\n"
"SUMMARY:Custom zone event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite,
"VTIMEZONE-defined TZID should make the calendar safe to rewrite");
require(loaded.events.size() == 1, "VTIMEZONE calendar should contain the event");
require(loaded.events[0].time_zone == "Custom/Example",
"VTIMEZONE-defined TZID should be retained on the event");
require(loaded.events[0].time_basis == nocal::TimeBasis::zoned,
"zoned time basis should be preserved");
require(loaded.vtimezones.size() == 1,
"VTIMEZONE definition should be captured");
require(loaded.vtimezones[0].tzid == "Custom/Example",
"VTIMEZONE TZID should match");
require(!loaded.vtimezones[0].raw.empty(),
"VTIMEZONE raw content should be captured");
// Verify a VTIMEZONE-DST warning was emitted for unknown TZID.
bool has_dst_warning = false;
for (const auto& w : loaded.warnings) {
if (w.find("DST") != std::string::npos) {
has_dst_warning = true;
break;
}
}
require(has_dst_warning,
"should warn that DST handling is approximate for custom VTIMEZONE");
// Round-trip: save with VTIMEZONE preserved, then reload.
const auto saved_revision = nocal::storage::IcsStore::save(
path, loaded.events, loaded.revision, loaded.vtimezones);
require(saved_revision.existed(), "save should return an existing revision");
const std::string bytes = read_fixture(path);
require(bytes.find("BEGIN:VTIMEZONE") != std::string::npos,
"VTIMEZONE block should be serialized on save");
require(bytes.find("TZID:Custom/Example") != std::string::npos,
"VTIMEZONE TZID should appear in serialized output");
require(bytes.find("BEGIN:STANDARD") != std::string::npos,
"VTIMEZONE sub-components should be preserved");
const auto reloaded = nocal::storage::IcsStore::load(path);
require(reloaded.safe_to_rewrite, "round-tripped VTIMEZONE calendar should be safe");
require(reloaded.events.size() == 1, "round-trip event count should match");
require(reloaded.events[0].time_zone == "Custom/Example",
"round-trip TZID should be preserved");
require(reloaded.vtimezones.size() == 1,
"round-trip VTIMEZONE count should match");
require(reloaded.vtimezones[0].tzid == "Custom/Example",
"round-trip VTIMEZONE TZID should match");
require_no_temporary_files(path);
}
void test_vtimezone_multiple_tzids(const std::filesystem::path& root) {
const auto path = root / "vtimezone-multiple.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/A\r\nEND:VTIMEZONE\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/B\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\n"
"UID:evt-a\r\n"
"DTSTART;TZID=Custom/A:20260717T100000\r\n"
"DTEND;TZID=Custom/A:20260717T110000\r\n"
"SUMMARY:Event A\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:evt-b\r\n"
"DTSTART;TZID=Custom/B:20260718T100000\r\n"
"DTEND;TZID=Custom/B:20260718T110000\r\n"
"SUMMARY:Event B\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite,
"multiple VTIMEZONE-defined TZIDs should be safe");
require(loaded.events.size() == 2, "should parse all events");
require(loaded.vtimezones.size() == 2, "should capture both VTIMEZONE definitions");
}
void test_vtimezone_undefined_tzid_still_unsafe(const std::filesystem::path& root) {
const auto path = root / "vtimezone-undefined.ics";
// Event uses Custom/Unknown which is NOT defined in any VTIMEZONE.
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/Defined\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\nUID:undef\r\n"
"DTSTART;TZID=Custom/Unknown:20260717T100000\r\n"
"DTEND;TZID=Custom/Unknown:20260717T110000\r\n"
"SUMMARY:Undefined TZID\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite,
"undefined TZID should still be unsafe even when other VTIMEZONEs exist");
require(loaded.events.size() == 1, "event should still be browseable");
}
} // namespace } // namespace
int main() { int main() {
@@ -842,7 +1057,13 @@ int main() {
test_supported_recurrence_and_exdates(root); test_supported_recurrence_and_exdates(root);
test_supported_rdates(root); test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root); test_unsupported_recurrence_and_zones_are_unsafe(root);
test_ordinal_byday_round_trip(root);
test_daily_with_byday_is_supported(root);
test_recurrence_id_round_trip(root);
test_save_validation_preserves_existing_bytes(root); test_save_validation_preserves_existing_bytes(root);
test_vtimezone_custom_tzid_safe_and_round_trips(root);
test_vtimezone_multiple_tzids(root);
test_vtimezone_undefined_tzid_still_unsafe(root);
std::filesystem::remove_all(root, ignored); std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n"; std::cout << "ics_tests: ok\n";
return 0; return 0;

View File

@@ -0,0 +1,899 @@
#include "nocal/storage/sync_cache.hpp"
#include "nocal/sync/microsoft_account.hpp"
#include "nocal/sync/microsoft_graph.hpp"
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/secret_store.hpp"
#include <cstdint>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <functional>
#include <iostream>
#include <map>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace {
using nocal::storage::CachedAccount;
using nocal::storage::SyncCache;
using nocal::sync::AuthorizationCallback;
using nocal::sync::AuthorizationCallbackReceiver;
using nocal::sync::BrowserLauncher;
using nocal::sync::EntropySource;
using nocal::sync::HttpMethod;
using nocal::sync::HttpRequest;
using nocal::sync::HttpResponse;
using nocal::sync::HttpTransport;
using nocal::sync::MicrosoftAccountError;
using nocal::sync::OAuthClientConfig;
using nocal::sync::OAuthSecretStore;
using nocal::sync::OAuthTokens;
using nocal::sync::CalDAVCredential;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string{message});
}
}
// -----------------------------------------------------------------------
// TempDirectory for a real SyncCache
// -----------------------------------------------------------------------
class TempDirectory {
public:
TempDirectory() {
std::string pattern =
(std::filesystem::temp_directory_path() / "nocal-microsoft-account-tests.XXXXXX")
.string();
std::vector<char> writable(pattern.begin(), pattern.end());
writable.push_back('\0');
const char* created = ::mkdtemp(writable.data());
if (created == nullptr) {
throw std::runtime_error("mkdtemp failed");
}
path_ = created;
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
// -----------------------------------------------------------------------
// ScriptedEntropy fixed deterministic values
// -----------------------------------------------------------------------
class ScriptedEntropy final : public EntropySource {
public:
explicit ScriptedEntropy(std::vector<std::array<std::byte, 32>> scripted_values)
: values(std::move(scripted_values)) {}
std::vector<std::array<std::byte, 32>> values;
std::size_t calls{0};
void fill(std::span<std::byte> output) override {
require(output.size() == 32, "entropy request was not exactly 32 bytes");
const std::size_t call = calls++;
require(call < values.size(), "unexpected entropy request");
std::copy(values[call].begin(), values[call].end(), output.begin());
}
};
// -----------------------------------------------------------------------
// ScriptedBrowser
// -----------------------------------------------------------------------
class ScriptedBrowser final : public BrowserLauncher {
public:
std::vector<std::string> urls;
void open(const std::string& url) override { urls.push_back(url); }
};
// -----------------------------------------------------------------------
// ScriptedReceiver copies state from browser URL if needed
// -----------------------------------------------------------------------
class ScriptedReceiver final : public AuthorizationCallbackReceiver {
public:
std::string redirect{"http://127.0.0.1:43123/callback"};
AuthorizationCallback callback;
std::size_t receives{0};
bool copy_browser_state{true};
ScriptedBrowser* browser{nullptr};
[[nodiscard]] std::string redirect_uri() const override { return redirect; }
AuthorizationCallback receive() override {
++receives;
if (copy_browser_state && browser != nullptr && !browser->urls.empty()) {
// Extract state from the authorization URL
const std::string& url = browser->urls.front();
const std::size_t state_pos = url.find("state=");
if (state_pos != std::string::npos) {
const std::size_t value_start = state_pos + 6;
const std::size_t amp = url.find('&', value_start);
const std::size_t len = (amp == std::string::npos) ? url.size() - value_start
: amp - value_start;
AuthorizationCallback result = callback;
result.state = url.substr(value_start, len);
return result;
}
}
return callback;
}
};
// -----------------------------------------------------------------------
// ScriptedTransport scripted responses for token exchange then /me
// -----------------------------------------------------------------------
class ScriptedTransport final : public HttpTransport {
public:
struct ResponseSpec {
int status{200};
std::string body;
bool throw_on_send{false};
std::string send_error_message{};
};
std::vector<HttpRequest> requests;
std::vector<ResponseSpec> responses;
HttpResponse send(const HttpRequest& request) override {
requests.push_back(request);
const std::size_t index = requests.size() - 1;
if (index >= responses.size()) {
return {200, {}, "{}"};
}
const ResponseSpec& spec = responses[index];
if (spec.throw_on_send) {
throw std::runtime_error(spec.send_error_message);
}
return {spec.status, {}, spec.body};
}
};
// -----------------------------------------------------------------------
// ScriptedSecretStore in-memory with injectable store failure
// -----------------------------------------------------------------------
class ScriptedSecretStore final : public OAuthSecretStore {
public:
std::map<std::string, OAuthTokens> store_data;
bool throw_on_store{false};
std::string store_error_message{};
std::size_t store_calls{0};
std::size_t load_calls{0};
std::size_t erase_calls{0};
void store(std::string account_id, const OAuthTokens& tokens, std::stop_token = {}) override {
++store_calls;
if (throw_on_store) {
throw std::runtime_error(store_error_message);
}
store_data[std::move(account_id)] = tokens;
}
[[nodiscard]] std::optional<OAuthTokens> load(
std::string account_id, std::stop_token = {}) override {
++load_calls;
const auto found = store_data.find(account_id);
if (found != store_data.end()) {
return found->second;
}
return std::nullopt;
}
void erase(std::string account_id, std::stop_token = {}) override {
++erase_calls;
store_data.erase(account_id);
}
void store_caldav_credential(std::string, const CalDAVCredential&,
std::stop_token = {}) override {
std::terminate();
}
[[nodiscard]] std::optional<CalDAVCredential> load_caldav_credential(
std::string, std::stop_token = {}) override {
std::terminate();
}
void erase_caldav_credential(std::string, std::stop_token = {}) override {
std::terminate();
}
};
// -----------------------------------------------------------------------
// Controllable clock
// -----------------------------------------------------------------------
class ControllableClock {
public:
std::int64_t value{1000};
[[nodiscard]] std::int64_t operator()() { return value; }
};
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
[[nodiscard]] std::array<std::byte, 32> zeros() { return {}; }
[[nodiscard]] std::string token_response_body(std::string_view access_token,
std::string_view refresh_token, std::int64_t expires_in) {
return "{\"access_token\":\"" + std::string(access_token)
+ "\",\"refresh_token\":\"" + std::string(refresh_token)
+ "\",\"token_type\":\"Bearer\",\"expires_in\":" + std::to_string(expires_in) + "}";
}
[[nodiscard]] std::string me_response_body(
std::string_view id, std::string_view display_name, std::string_view user_principal_name) {
std::string body = "{\"id\":\"" + std::string(id) + "\"";
if (!display_name.empty()) {
body += ",\"displayName\":\"" + std::string(display_name) + "\"";
}
if (!user_principal_name.empty()) {
body += ",\"userPrincipalName\":\"" + std::string(user_principal_name) + "\"";
}
body += "}";
return body;
}
// -----------------------------------------------------------------------
// Test: Happy path
// -----------------------------------------------------------------------
void test_happy_path() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, token_response_body("access-token-value", "refresh-token-value", 3600)});
transport.responses.push_back(
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
const CachedAccount account = nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
// Verify returned account
require(account.provider == "microsoft-graph", "wrong provider");
// Compute expected account id
const std::string expected_id =
nocal::sync::microsoft_account_id("remote-subject");
require(account.id == expected_id, "account id mismatch");
require(account.remote_subject == "remote-subject", "remote subject mismatch");
require(account.display_name == "Display Name", "display name mismatch");
// Cache snapshot has exactly one account
const auto snapshot = cache.snapshot();
require(snapshot.accounts.size() == 1, "expected exactly one cached account");
require(snapshot.accounts.front() == account, "cached account differs from returned");
// Secret store holds the parsed tokens under the same id
const auto stored_tokens = secret_store.store_data.find(expected_id);
require(stored_tokens != secret_store.store_data.end(), "tokens not stored");
require(stored_tokens->second.access_token == "access-token-value",
"wrong stored access token");
require(stored_tokens->second.refresh_token == "refresh-token-value",
"wrong stored refresh token");
require(stored_tokens->second.token_type == "Bearer", "wrong stored token type");
// Exactly one browser URL, one token request, one /me GET with Bearer token
require(browser.urls.size() == 1, "expected exactly one browser URL");
require(transport.requests.size() == 2, "expected exactly two HTTP requests");
require(transport.requests[0].method == HttpMethod::post,
"first request was not POST (token)");
require(transport.requests[0].url == "https://login.example/token",
"first request URL mismatch");
require(transport.requests[1].method == HttpMethod::get,
"second request was not GET (/me)");
require(transport.requests[1].url.find("graph.microsoft.com") != std::string::npos,
"second request URL does not contain graph.microsoft.com");
bool has_bearer = false;
for (const auto& header : transport.requests[1].headers) {
if (header.name == "Authorization") {
has_bearer = header.value.find("Bearer access-token-value") != std::string::npos;
}
}
require(has_bearer, "/me request did not carry Bearer token");
}
// -----------------------------------------------------------------------
// Test: Idempotent reconnect
// -----------------------------------------------------------------------
void test_idempotent_reconnect() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
auto do_connect = [&]() -> std::string {
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, token_response_body("access-token-" + std::to_string(transport.requests.size()),
"refresh-token-" + std::to_string(transport.requests.size()), 3600)});
transport.responses.push_back(
{200, me_response_body("same-subject", "Same User", "same@example.com")});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
const CachedAccount account = nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
return account.id;
};
const std::string first_id = do_connect();
const std::string second_id = do_connect();
// Same account id
require(first_id == second_id, "reconnect produced different account id");
// Cache still has exactly one account
const auto snapshot = cache.snapshot();
require(snapshot.accounts.size() == 1, "expected exactly one account after reconnect");
require(snapshot.accounts.front().id == first_id, "account id mismatch in cache");
}
// -----------------------------------------------------------------------
// Test: Authorization rejected
// -----------------------------------------------------------------------
void test_authorization_rejected() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"", "", "access_denied", ""};
ScriptedTransport transport;
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
bool caught = false;
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const MicrosoftAccountError& e) {
caught = true;
require(std::string(e.what()) == "Microsoft authorization failed",
"wrong error message on authorization rejection");
} catch (const std::exception& e) {
throw std::runtime_error(
std::string("wrong exception type: ") + e.what());
}
require(caught, "expected MicrosoftAccountError");
// Cache and secret store both untouched
require(cache.snapshot().accounts.empty(), "cache was modified on authorization rejection");
require(secret_store.store_calls == 0, "secret store was called on authorization rejection");
}
// -----------------------------------------------------------------------
// Test: Token endpoint returns non-2xx
// -----------------------------------------------------------------------
void test_token_endpoint_non2xx() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back({400, "{\"error\":\"invalid_grant\"}"});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
bool caught = false;
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const MicrosoftAccountError& e) {
caught = true;
require(std::string(e.what()) == "Microsoft authorization failed",
"wrong error message on token rejection");
}
require(caught, "expected MicrosoftAccountError");
// Nothing stored
require(cache.snapshot().accounts.empty(), "cache was modified on token error");
require(secret_store.store_calls == 0, "secret store was called on token error");
}
// -----------------------------------------------------------------------
// Test: Token response missing refresh_token
// -----------------------------------------------------------------------
void test_token_response_missing_refresh() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
// No refresh_token in response — parse_oauth_token_response will reject
transport.responses.push_back(
{200, "{\"access_token\":\"acc\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
bool caught = false;
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const MicrosoftAccountError& e) {
caught = true;
require(std::string(e.what()) == "Microsoft token response invalid",
"wrong error message on missing refresh token");
}
require(caught, "expected MicrosoftAccountError");
// Nothing stored
require(cache.snapshot().accounts.empty(), "cache was modified on parse error");
require(secret_store.store_calls == 0, "secret store was called on parse error");
}
// -----------------------------------------------------------------------
// Test: /me failure
// -----------------------------------------------------------------------
void test_me_failure() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
// Successful token response
transport.responses.push_back(
{200, token_response_body("acc-token", "ref-token", 3600)});
// /me returns garbage (malformed JSON)
transport.responses.push_back({200, "not-valid-json"});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
bool caught = false;
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const MicrosoftAccountError& e) {
caught = true;
require(std::string(e.what()) == "Microsoft identity read failed",
"wrong error message on /me failure");
}
require(caught, "expected MicrosoftAccountError");
// Cache and secret store untouched
require(cache.snapshot().accounts.empty(), "cache was modified on /me failure");
require(secret_store.store_calls == 0, "secret store was called on /me failure");
}
// -----------------------------------------------------------------------
// Test: Secret-store store() failure — cache retains the account row
// -----------------------------------------------------------------------
void test_secret_store_failure() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, token_response_body("acc-token", "ref-token", 3600)});
transport.responses.push_back(
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
ScriptedSecretStore secret_store;
secret_store.throw_on_store = true;
secret_store.store_error_message = "secret service unavailable";
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
bool caught = false;
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const MicrosoftAccountError& e) {
caught = true;
require(std::string(e.what()) == "credential storage failed",
"wrong error message on store failure");
}
require(caught, "expected MicrosoftAccountError");
// Cache DOES retain the account row (documented disconnected state)
const auto snapshot = cache.snapshot();
require(snapshot.accounts.size() == 1,
"cache should retain the account row even when secret store fails");
require(snapshot.accounts.front().remote_subject == "remote-subject",
"cached account has wrong remote subject");
// Verify secret store does NOT have the credentials
const std::string expected_id =
nocal::sync::microsoft_account_id("remote-subject");
const auto stored = secret_store.store_data.find(expected_id);
require(stored == secret_store.store_data.end(),
"secret store should not contain credentials after store failure");
require(secret_store.store_calls == 1, "store should have been attempted once");
// A subsequent successful connect should complete (idempotent)
ScriptedEntropy entropy2{{zeros(), zeros()}};
ScriptedBrowser browser2;
ScriptedReceiver receiver2;
receiver2.browser = &browser2;
receiver2.callback = {"auth-code-2", "", "", ""};
ScriptedTransport transport2;
transport2.responses.push_back(
{200, token_response_body("acc-token-2", "ref-token-2", 3600)});
transport2.responses.push_back(
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
ScriptedSecretStore secret_store2;
ControllableClock clock2;
const CachedAccount retry_account = nocal::sync::connect_microsoft_account(
config, browser2, receiver2, transport2, entropy2, secret_store2, cache,
[&clock2]() { return clock2(); });
require(retry_account.id == expected_id,
"retry account id should match deterministic id");
require(cache.snapshot().accounts.size() == 1,
"cache should still have exactly one account after successful retry");
const auto stored2 = secret_store2.store_data.find(expected_id);
require(stored2 != secret_store2.store_data.end(),
"credentials should be stored after successful retry");
}
// -----------------------------------------------------------------------
// Test: Empty displayName falls back to userPrincipalName
// -----------------------------------------------------------------------
void test_empty_display_name_fallback() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, token_response_body("acc-token", "ref-token", 3600)});
// No displayName in /me response
transport.responses.push_back(
{200, me_response_body("remote-subject", "", "fallback@example.com")});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
const CachedAccount account = nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
require(account.display_name == "fallback@example.com",
"display name should fall back to userPrincipalName");
}
// -----------------------------------------------------------------------
// Test: default_microsoft_client_id with env unset and set
// -----------------------------------------------------------------------
void test_default_client_id() {
// Save original env state and unset
const char* original = std::getenv("NOCAL_MICROSOFT_CLIENT_ID");
// Test with env unset
if (original != nullptr) {
::unsetenv("NOCAL_MICROSOFT_CLIENT_ID");
}
const std::string unset_result = nocal::sync::default_microsoft_client_id();
require(unset_result == nocal::sync::microsoft_client_id_placeholder,
"default client id should be placeholder when env is unset");
// Test with env set
::setenv("NOCAL_MICROSOFT_CLIENT_ID", "my-test-client-id", 1);
const std::string set_result = nocal::sync::default_microsoft_client_id();
require(set_result == "my-test-client-id",
"default client id should match env when set");
// Restore original
if (original != nullptr) {
::setenv("NOCAL_MICROSOFT_CLIENT_ID", original, 1);
} else {
::unsetenv("NOCAL_MICROSOFT_CLIENT_ID");
}
}
// -----------------------------------------------------------------------
// Test: microsoft_oauth_config
// -----------------------------------------------------------------------
void test_microsoft_oauth_config() {
const OAuthClientConfig config = nocal::sync::microsoft_oauth_config("my-client-id");
require(config.client_id == "my-client-id", "client_id mismatch");
require(config.authorization_endpoint
== "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"wrong authorization endpoint");
require(config.token_endpoint
== "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"wrong token endpoint");
require(config.scopes.size() == 3, "expected exactly 3 scopes");
require(config.scopes[0] == "offline_access", "scope[0] mismatch");
require(config.scopes[1] == "User.Read", "scope[1] mismatch");
require(config.scopes[2] == "Calendars.Read", "scope[2] mismatch");
}
// -----------------------------------------------------------------------
// Test: Error messages never contain credential material
// -----------------------------------------------------------------------
void test_error_message_redaction() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
const std::string access_marker = "ACCESS_CRED_MARKER_12345";
const std::string refresh_marker = "REFRESH_CRED_MARKER_67890";
const std::string auth_code_marker = "AUTH_CODE_MARKER_ABCDE";
// Test 1: Token endpoint error (400)
{
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {auth_code_marker, "", "", ""};
ScriptedTransport transport;
transport.responses.push_back({400, access_marker + refresh_marker});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const std::exception& e) {
const std::string msg = e.what();
require(msg.find(access_marker) == std::string::npos,
"error leaked access token on 400");
require(msg.find(refresh_marker) == std::string::npos,
"error leaked refresh token on 400");
require(msg.find(auth_code_marker) == std::string::npos,
"error leaked auth code on 400");
}
}
// Test 2: Parse error (missing refresh_token)
{
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {auth_code_marker, "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, "{\"access_token\":\"" + access_marker
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const std::exception& e) {
const std::string msg = e.what();
require(msg.find(access_marker) == std::string::npos,
"error leaked access token on parse failure");
require(msg.find(auth_code_marker) == std::string::npos,
"error leaked auth code on parse failure");
}
}
// Test 3: /me failure
{
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {auth_code_marker, "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, "{\"access_token\":\"" + access_marker
+ "\",\"refresh_token\":\"" + refresh_marker
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
transport.responses.push_back(
{200, "{\"bad_field\":" + access_marker + "}"});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const std::exception& e) {
const std::string msg = e.what();
require(msg.find(access_marker) == std::string::npos,
"error leaked access token on /me failure");
require(msg.find(refresh_marker) == std::string::npos,
"error leaked refresh token on /me failure");
require(msg.find(auth_code_marker) == std::string::npos,
"error leaked auth code on /me failure");
}
}
// Test 4: Secret store failure
{
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {auth_code_marker, "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, "{\"access_token\":\"" + access_marker
+ "\",\"refresh_token\":\"" + refresh_marker
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
transport.responses.push_back(
{200, me_response_body("remote-subject", "Display", "user@example.com")});
ScriptedSecretStore secret_store;
secret_store.throw_on_store = true;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
try {
(void)nocal::sync::connect_microsoft_account(
config, browser, receiver, transport, entropy, secret_store, cache,
[&clock]() { return clock(); });
} catch (const std::exception& e) {
const std::string msg = e.what();
require(msg.find(access_marker) == std::string::npos,
"error leaked access token on store failure");
require(msg.find(refresh_marker) == std::string::npos,
"error leaked refresh token on store failure");
require(msg.find(auth_code_marker) == std::string::npos,
"error leaked auth code on store failure");
}
}
}
// -----------------------------------------------------------------------
// Test: microsoft_account_id is deterministic
// -----------------------------------------------------------------------
void test_microsoft_account_id_deterministic() {
const std::string first = nocal::sync::microsoft_account_id("user@example.com");
const std::string second = nocal::sync::microsoft_account_id("user@example.com");
require(
first == second, "microsoft_account_id is not deterministic for same input");
const std::string different =
nocal::sync::microsoft_account_id("other@example.com");
require(first != different,
"microsoft_account_id should differ for different remote subjects");
}
} // namespace
int main() {
try {
test_happy_path();
test_idempotent_reconnect();
test_authorization_rejected();
test_token_endpoint_non2xx();
test_token_response_missing_refresh();
test_me_failure();
test_secret_store_failure();
test_empty_display_name_fallback();
test_default_client_id();
test_microsoft_oauth_config();
test_error_message_redaction();
test_microsoft_account_id_deterministic();
} catch (const std::exception& error) {
std::cerr << "microsoft account tests failed: " << error.what() << '\n';
return 1;
}
std::cout << "microsoft account tests passed\n";
return 0;
}

View File

@@ -0,0 +1,718 @@
#include "nocal/storage/sync_cache.hpp"
#include "nocal/sync/microsoft_account.hpp"
#include "nocal/sync/microsoft_graph.hpp"
#include "nocal/sync/microsoft_sync_provider.hpp"
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/provider.hpp"
#include "nocal/sync/secret_store.hpp"
#include "nocal/sync/token_broker.hpp"
#include <cstdint>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <functional>
#include <iostream>
#include <map>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace {
using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::SyncCache;
using nocal::sync::AccountDisconnected;
using nocal::sync::AuthorizationCallback;
using nocal::sync::AuthorizationCallbackReceiver;
using nocal::sync::BrowserLauncher;
using nocal::sync::ConnectedAccount;
using nocal::sync::EntropySource;
using nocal::sync::HttpMethod;
using nocal::sync::HttpRequest;
using nocal::sync::HttpResponse;
using nocal::sync::HttpTransport;
using nocal::sync::OAuthClientConfig;
using nocal::sync::OAuthSecretStore;
using nocal::sync::OAuthTokens;
using nocal::sync::CalDAVCredential;
using nocal::sync::SyncObserver;
using nocal::sync::SyncProgressEvent;
using nocal::sync::SyncStage;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string{message});
}
}
// -----------------------------------------------------------------------
// TempDirectory for a real SyncCache
// -----------------------------------------------------------------------
class TempDirectory {
public:
TempDirectory() {
std::string pattern =
(std::filesystem::temp_directory_path()
/ "nocal-microsoft-sync-provider-tests.XXXXXX")
.string();
std::vector<char> writable(pattern.begin(), pattern.end());
writable.push_back('\0');
const char* created = ::mkdtemp(writable.data());
if (created == nullptr) {
throw std::runtime_error("mkdtemp failed");
}
path_ = created;
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
[[nodiscard]] const std::filesystem::path& path() const noexcept {
return path_;
}
private:
std::filesystem::path path_;
};
// -----------------------------------------------------------------------
// ScriptedEntropy
// -----------------------------------------------------------------------
class ScriptedEntropy final : public EntropySource {
public:
explicit ScriptedEntropy(std::vector<std::array<std::byte, 32>> sc)
: values(std::move(sc)) {}
std::vector<std::array<std::byte, 32>> values;
std::size_t calls{0};
void fill(std::span<std::byte> output) override {
require(output.size() == 32, "entropy request was not exactly 32 bytes");
require(calls < values.size(), "unexpected entropy request");
std::copy(values[calls].begin(), values[calls].end(), output.begin());
++calls;
}
};
// -----------------------------------------------------------------------
// ScriptedBrowser
// -----------------------------------------------------------------------
class ScriptedBrowser final : public BrowserLauncher {
public:
std::vector<std::string> urls;
void open(const std::string& url) override { urls.push_back(url); }
};
// -----------------------------------------------------------------------
// ScriptedReceiver
// -----------------------------------------------------------------------
class ScriptedReceiver final : public AuthorizationCallbackReceiver {
public:
std::string redirect{"http://127.0.0.1:43123/callback"};
AuthorizationCallback callback;
std::size_t receives{0};
bool copy_browser_state{true};
ScriptedBrowser* browser{nullptr};
[[nodiscard]] std::string redirect_uri() const override {
return redirect;
}
AuthorizationCallback receive() override {
++receives;
if (copy_browser_state && browser != nullptr && !browser->urls.empty()) {
const std::string& url = browser->urls.front();
const std::size_t state_pos = url.find("state=");
if (state_pos != std::string::npos) {
const std::size_t value_start = state_pos + 6;
const std::size_t amp = url.find('&', value_start);
const std::size_t len =
(amp == std::string::npos) ? url.size() - value_start
: amp - value_start;
AuthorizationCallback result = callback;
result.state = url.substr(value_start, len);
return result;
}
}
return callback;
}
};
// -----------------------------------------------------------------------
// ScriptedTransport
// -----------------------------------------------------------------------
class ScriptedTransport final : public HttpTransport {
public:
struct ResponseSpec {
int status{200};
std::string body;
bool throw_on_send{false};
std::string send_error_message{};
};
std::vector<HttpRequest> requests;
std::vector<ResponseSpec> responses;
std::size_t fail_at{static_cast<std::size_t>(-1)};
HttpResponse send(const HttpRequest& request) override {
requests.push_back(request);
const std::size_t index = requests.size() - 1;
if (index == fail_at) {
throw std::runtime_error("transport failure");
}
if (index >= responses.size()) {
return {200, {}, "{}"};
}
const ResponseSpec& spec = responses[index];
if (spec.throw_on_send) {
throw std::runtime_error(spec.send_error_message);
}
return {spec.status, {}, spec.body};
}
};
// -----------------------------------------------------------------------
// ScriptedSecretStore (token_broker style with per-account script)
// -----------------------------------------------------------------------
class ScriptedSecretStore final : public OAuthSecretStore {
public:
struct StoreResult {
bool throw_on_store{false};
std::string store_error_message{};
};
std::map<std::string, OAuthTokens> store_data;
std::map<std::string, StoreResult> store_script;
std::size_t store_calls{0};
std::size_t load_calls{0};
std::size_t erase_calls{0};
void store(std::string account_id, const OAuthTokens& tokens,
std::stop_token = {}) override {
++store_calls;
const auto found = store_script.find(account_id);
if (found != store_script.end() && found->second.throw_on_store) {
throw std::runtime_error(found->second.store_error_message);
}
store_data[std::move(account_id)] = tokens;
}
[[nodiscard]] std::optional<OAuthTokens> load(
std::string account_id, std::stop_token = {}) override {
++load_calls;
const auto stored = store_data.find(account_id);
if (stored != store_data.end()) {
return stored->second;
}
return std::nullopt;
}
void erase(std::string account_id, std::stop_token = {}) override {
++erase_calls;
store_data.erase(account_id);
}
void store_caldav_credential(std::string, const CalDAVCredential&,
std::stop_token = {}) override {
std::terminate();
}
[[nodiscard]] std::optional<CalDAVCredential> load_caldav_credential(
std::string, std::stop_token = {}) override {
std::terminate();
}
void erase_caldav_credential(std::string, std::stop_token = {}) override {
std::terminate();
}
};
// -----------------------------------------------------------------------
// TestObserver - records progress events
// -----------------------------------------------------------------------
class TestObserver final : public SyncObserver {
public:
std::vector<SyncProgressEvent> events;
void on_sync_progress(const SyncProgressEvent& event) override {
events.push_back(event);
}
};
// -----------------------------------------------------------------------
// Controllable clock
// -----------------------------------------------------------------------
class ControllableClock {
public:
std::int64_t value{1000};
[[nodiscard]] std::int64_t operator()() { return value; }
};
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
[[nodiscard]] std::array<std::byte, 32> zeros() { return {}; }
[[nodiscard]] std::string token_response_body(std::string_view access_token,
std::string_view refresh_token, std::int64_t expires_in) {
return "{\"access_token\":\"" + std::string(access_token)
+ "\",\"refresh_token\":\"" + std::string(refresh_token)
+ "\",\"token_type\":\"Bearer\",\"expires_in\":"
+ std::to_string(expires_in) + "}";
}
[[nodiscard]] std::string me_response_body(
std::string_view id, std::string_view display_name, std::string_view upn) {
std::string body = "{\"id\":\"" + std::string(id) + "\"";
if (!display_name.empty()) {
body += ",\"displayName\":\"" + std::string(display_name) + "\"";
}
if (!upn.empty()) {
body += ",\"userPrincipalName\":\"" + std::string(upn) + "\"";
}
body += "}";
return body;
}
// Build a Graph /me/calendars response with one calendar
[[nodiscard]] std::string calendars_response_body(
const std::vector<std::string>& remote_ids,
const std::vector<std::string>& names,
const std::vector<bool>& is_default) {
require(remote_ids.size() == names.size(),
"calendars_response_body argument size mismatch");
require(remote_ids.size() == is_default.size(),
"calendars_response_body argument size mismatch");
std::string body = "{\"value\":[";
for (std::size_t i = 0; i < remote_ids.size(); ++i) {
if (i > 0) {
body += ",";
}
body += "{\"id\":\"" + remote_ids[i] + "\""
+ ",\"name\":\"" + names[i] + "\""
+ ",\"isDefaultCalendar\":" + (is_default[i] ? "true" : "false")
+ ",\"canEdit\":true,\"changeKey\":\"ck\",\"hexColor\":\"#000000\"}";
}
body += "]}";
return body;
}
// Build an empty calendar view delta response (no events)
[[nodiscard]] std::string empty_delta_response() {
return "{\"value\":[],\"@odata.deltaLink\":\""
"https://graph.microsoft.com/v1.0/me/calendarView/delta?"
"deltatoken=final\"}";
}
// Build an empty calendarView response
[[nodiscard]] std::string empty_calendar_view_response() {
return "{\"value\":[]}";
}
// -----------------------------------------------------------------------
// Test: provider_id returns "microsoft-graph"
// -----------------------------------------------------------------------
void test_provider_id() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedTransport transport;
ScriptedSecretStore secret_store;
ScriptedBrowser browser;
ScriptedReceiver receiver;
ScriptedEntropy entropy{std::vector<std::array<std::byte, 32>>{}};
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
nocal::sync::OAuthTokenBroker broker(
config, transport, secret_store, [&clock]() { return clock(); });
nocal::sync::MicrosoftAccountSyncProvider provider(
broker, secret_store, cache, transport, config,
[&clock]() { return clock(); }, browser, receiver, entropy);
require(provider.provider_id() == "microsoft-graph",
"provider_id should return 'microsoft-graph'");
}
// -----------------------------------------------------------------------
// Test: connect_account happy path
// -----------------------------------------------------------------------
void test_connect_account_happy_path() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, token_response_body("access-token-value", "refresh-token-value", 3600)});
transport.responses.push_back(
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
nocal::sync::OAuthTokenBroker broker(
config, transport, secret_store, [&clock]() { return clock(); });
nocal::sync::MicrosoftAccountSyncProvider provider(
broker, secret_store, cache, transport, config,
[&clock]() { return clock(); }, browser, receiver, entropy);
const ConnectedAccount result = provider.connect_account();
// Verify returned account
const std::string expected_id =
nocal::sync::microsoft_account_id("remote-subject");
require(result.account_id == expected_id, "account id mismatch");
require(result.display_name == "Display Name", "display name mismatch");
// Cache has the account
const auto snapshot = cache.snapshot();
require(snapshot.accounts.size() == 1, "expected exactly one cached account");
require(snapshot.accounts.front().id == expected_id, "cached account id mismatch");
require(snapshot.accounts.front().provider == "microsoft-graph",
"cached account provider mismatch");
// Secret store has the tokens
const auto stored = secret_store.store_data.find(expected_id);
require(stored != secret_store.store_data.end(), "tokens not stored");
require(stored->second.access_token == "access-token-value",
"wrong stored access token");
require(stored->second.token_type == "Bearer", "wrong stored token type");
// Exactly one browser URL, one token request, one /me request
require(browser.urls.size() == 1, "expected exactly one browser URL");
require(transport.requests.size() == 2, "expected exactly two HTTP requests");
require(transport.requests[0].method == HttpMethod::post,
"first request was not POST (token)");
require(transport.requests[1].method == HttpMethod::get,
"second request was not GET (/me)");
}
// -----------------------------------------------------------------------
// Test: disconnect_account erases secret
// -----------------------------------------------------------------------
void test_disconnect_account() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport transport;
transport.responses.push_back(
{200, token_response_body("access-token-value", "refresh-token-value", 3600)});
transport.responses.push_back(
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
nocal::sync::OAuthTokenBroker broker(
config, transport, secret_store, [&clock]() { return clock(); });
nocal::sync::MicrosoftAccountSyncProvider provider(
broker, secret_store, cache, transport, config,
[&clock]() { return clock(); }, browser, receiver, entropy);
const ConnectedAccount result = provider.connect_account();
// Verify token exists before disconnect
require(secret_store.store_data.find(result.account_id) != secret_store.store_data.end(),
"tokens should exist before disconnect");
require(secret_store.erase_calls == 0, "erase should not have been called yet");
// Disconnect
provider.disconnect_account(result.account_id);
// Tokens erased
require(secret_store.store_data.find(result.account_id) == secret_store.store_data.end(),
"tokens should be erased after disconnect");
require(secret_store.erase_calls == 1, "erase should have been called once");
// Cache still has the account row (documented behavior)
const auto snapshot = cache.snapshot();
require(snapshot.accounts.size() == 1,
"cache should retain the account row after disconnect");
}
// -----------------------------------------------------------------------
// Test: sync_account progress events (happy path)
// -----------------------------------------------------------------------
void test_sync_account_progress() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
// First, connect an account (so we have tokens + account in cache)
ScriptedEntropy connect_entropy{{zeros(), zeros()}};
ScriptedBrowser connect_browser;
ScriptedReceiver connect_receiver;
connect_receiver.browser = &connect_browser;
connect_receiver.callback = {"auth-code", "", "", ""};
ScriptedTransport connect_transport;
connect_transport.responses.push_back(
{200, token_response_body("connect-access", "connect-refresh", 3600)});
connect_transport.responses.push_back(
{200, me_response_body("sync-subject", "Sync User", "sync@example.com")});
ScriptedSecretStore secret_store;
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
nocal::sync::OAuthTokenBroker broker(
config, connect_transport, secret_store, [&clock]() { return clock(); });
ScriptedEntropy sync_entropy{{zeros(), zeros()}};
nocal::sync::MicrosoftAccountSyncProvider connect_provider(
broker, secret_store, cache, connect_transport, config,
[&clock]() { return clock(); }, connect_browser, connect_receiver,
sync_entropy);
const ConnectedAccount result = connect_provider.connect_account();
const std::string account_id = result.account_id;
// Now set up a new transport for sync (identity refresh + calendar
// discovery + primary calendar delta + secondary calendar view)
ScriptedTransport sync_transport;
// The token broker will see the stored tokens are still valid (expiry at
// 4600, clock at 1000, skew 300 → 1300 < 4600 → no refresh needed).
// So we need responses for:
// 0: /me (identity refresh)
// 1: /me/calendars
// 2: primary calendar delta (first page with deltaLink)
// 3: secondary calendar calendarView (first page with no nextLink)
//
// But the token broker won't call the transport since tokens are valid.
// So sync transport responses start at 0.
// First check what stored tokens look like: expires_at = 1000 + 3600 = 4600
// Clock at 1000, skew 300 → 1000+300 = 1300 < 4600 so no refresh needed.
sync_transport.responses.push_back(
{200, me_response_body("sync-subject", "Sync User", "sync@example.com")});
// Two calendars: primary + secondary
sync_transport.responses.push_back(
{200, calendars_response_body({"AAMkAGprimary", "AAMkAGsecondary"},
{"Primary", "Secondary"}, {true, false})});
// Primary delta: empty page with deltaLink → completion
sync_transport.responses.push_back({200, empty_delta_response()});
// Secondary calendarView: empty page
sync_transport.responses.push_back({200, empty_calendar_view_response()});
TestObserver observer;
// Recreate broker with the new transport
nocal::sync::OAuthTokenBroker sync_broker(
config, sync_transport, secret_store, [&clock]() { return clock(); });
ScriptedEntropy sync_entropy2{{zeros(), zeros()}};
nocal::sync::MicrosoftAccountSyncProvider sync_provider(
sync_broker, secret_store, cache, sync_transport, config,
[&clock]() { return clock(); }, connect_browser, connect_receiver,
sync_entropy2);
sync_provider.sync_account(account_id, observer);
// Verify progress events
// Expected order:
// 1. authenticating
// 2. refreshing_identity
// 3. discovering_calendars
// 4. syncing_calendar (primary, 0/2)
// 5. syncing_calendar (secondary, 1/2)
// 6. completed
require(observer.events.size() == 6,
"expected 6 progress events, got " + std::to_string(observer.events.size()));
require(observer.events[0].stage == SyncStage::authenticating,
"event[0] should be authenticating");
require(observer.events[1].stage == SyncStage::refreshing_identity,
"event[1] should be refreshing_identity");
require(observer.events[2].stage == SyncStage::discovering_calendars,
"event[2] should be discovering_calendars");
require(observer.events[3].stage == SyncStage::syncing_calendar,
"event[3] should be syncing_calendar");
require(observer.events[3].calendars_completed == 0,
"event[3] calendars_completed should be 0");
require(observer.events[3].calendars_total == 2,
"event[3] calendars_total should be 2");
require(observer.events[4].stage == SyncStage::syncing_calendar,
"event[4] should be syncing_calendar");
require(observer.events[4].calendars_completed == 1,
"event[4] calendars_completed should be 1");
require(observer.events[4].calendars_total == 2,
"event[4] calendars_total should be 2");
require(observer.events[5].stage == SyncStage::completed,
"event[5] should be completed");
}
// -----------------------------------------------------------------------
// Test: sync_account throws AccountDisconnected when tokens refresh fails
// -----------------------------------------------------------------------
void test_sync_account_account_disconnected() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
// Set up an account with expired tokens
ScriptedTransport connect_transport;
connect_transport.responses.push_back(
{200, token_response_body("connect-access", "connect-refresh", 1)});
connect_transport.responses.push_back(
{200, me_response_body("disco-subject", "Disco User", "disco@example.com")});
ScriptedSecretStore secret_store;
ScriptedEntropy entropy{{zeros(), zeros()}};
ScriptedBrowser browser;
ScriptedReceiver receiver;
receiver.browser = &browser;
receiver.callback = {"auth-code", "", "", ""};
ControllableClock clock;
clock.value = 1000;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
nocal::sync::OAuthTokenBroker connect_broker(
config, connect_transport, secret_store, [&clock]() { return clock(); });
nocal::sync::MicrosoftAccountSyncProvider connect_provider(
connect_broker, secret_store, cache, connect_transport, config,
[&clock]() { return clock(); }, browser, receiver, entropy);
const ConnectedAccount result = connect_provider.connect_account();
// Token expires at 1000 + 1 = 1001. Now advance clock to 2000, well past
// expiry + skew (1001 < 2000+300=2300, so expired).
clock.value = 2000;
// Set up transport that returns 400 on the refresh attempt
ScriptedTransport refresh_transport;
refresh_transport.responses.push_back({400, "{\"error\":\"invalid_grant\"}"});
nocal::sync::OAuthTokenBroker refresh_broker(
config, refresh_transport, secret_store, [&clock]() { return clock(); });
ScriptedEntropy sync_entropy_disco{{zeros(), zeros()}};
nocal::sync::MicrosoftAccountSyncProvider sync_provider(
refresh_broker, secret_store, cache, refresh_transport, config,
[&clock]() { return clock(); }, browser, receiver,
sync_entropy_disco);
TestObserver observer;
bool caught = false;
try {
sync_provider.sync_account(result.account_id, observer);
} catch (const AccountDisconnected&) {
caught = true;
}
require(caught, "expected AccountDisconnected when tokens refresh fails");
// Observer should have received at least the authenticating event
require(!observer.events.empty(), "observer should have received at least one event");
require(observer.events.front().stage == SyncStage::authenticating,
"first event should be authenticating");
}
// -----------------------------------------------------------------------
// Test: sync_account throws when no tokens exist
// -----------------------------------------------------------------------
void test_sync_account_no_tokens() {
TempDirectory temp;
SyncCache cache(temp.path() / "cache.db");
ScriptedSecretStore secret_store; // empty store
ScriptedTransport transport;
ScriptedBrowser browser;
ScriptedReceiver receiver;
ScriptedEntropy entropy{std::vector<std::array<std::byte, 32>>{}};
ControllableClock clock;
OAuthClientConfig config;
config.client_id = "test-client-id";
config.authorization_endpoint = "https://login.example/auth";
config.token_endpoint = "https://login.example/token";
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
nocal::sync::OAuthTokenBroker broker(
config, transport, secret_store, [&clock]() { return clock(); });
nocal::sync::MicrosoftAccountSyncProvider provider(
broker, secret_store, cache, transport, config,
[&clock]() { return clock(); }, browser, receiver, entropy);
TestObserver observer;
bool caught = false;
try {
provider.sync_account("nonexistent-account", observer);
} catch (const std::runtime_error&) {
caught = true;
}
require(caught, "expected std::runtime_error when no tokens exist");
}
} // namespace
int main() {
try {
test_provider_id();
test_connect_account_happy_path();
test_disconnect_account();
test_sync_account_progress();
test_sync_account_account_disconnected();
test_sync_account_no_tokens();
} catch (const std::exception& error) {
std::cerr << "microsoft sync provider tests failed: " << error.what() << '\n';
return 1;
}
std::cout << "microsoft sync provider tests passed\n";
return 0;
}

View File

@@ -0,0 +1,655 @@
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/secret_store.hpp"
#include "nocal/sync/token_broker.hpp"
#include <cstdint>
#include <exception>
#include <iostream>
#include <map>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace {
using nocal::sync::AccountDisconnected;
using nocal::sync::HttpHeader;
using nocal::sync::HttpRequest;
using nocal::sync::HttpResponse;
using nocal::sync::HttpTransport;
using nocal::sync::OAuthClientConfig;
using nocal::sync::OAuthSecretStore;
using nocal::sync::OAuthTokens;
using nocal::sync::CalDAVCredential;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string(message));
}
}
// Fake OAuthSecretStore with scriptable behavior
class ScriptedSecretStore final : public OAuthSecretStore {
public:
struct LoadResult {
std::optional<OAuthTokens> tokens;
bool throw_on_load{false};
std::string load_error_message{};
};
struct StoreResult {
bool throw_on_store{false};
std::string store_error_message{};
};
struct EraseResult {
bool throw_on_erase{false};
std::string erase_error_message{};
};
std::map<std::string, OAuthTokens> store_data;
std::map<std::string, LoadResult> load_script;
std::map<std::string, StoreResult> store_script;
std::map<std::string, EraseResult> erase_script;
std::size_t load_calls{0};
std::size_t store_calls{0};
std::size_t erase_calls{0};
void store(std::string account_id, const OAuthTokens& tokens,
std::stop_token = {}) override {
++store_calls;
const auto found = store_script.find(account_id);
if (found != store_script.end() && found->second.throw_on_store) {
throw std::runtime_error(found->second.store_error_message);
}
store_data[std::move(account_id)] = tokens;
}
[[nodiscard]] std::optional<OAuthTokens> load(
std::string account_id, std::stop_token = {}) override {
++load_calls;
const auto found = load_script.find(account_id);
if (found != load_script.end() && found->second.throw_on_load) {
throw std::runtime_error(found->second.load_error_message);
}
const auto stored = store_data.find(account_id);
if (stored != store_data.end()) {
return stored->second;
}
if (found != load_script.end() && found->second.tokens.has_value()) {
return found->second.tokens;
}
return std::nullopt;
}
void erase(std::string account_id, std::stop_token = {}) override {
++erase_calls;
const auto found = erase_script.find(account_id);
if (found != erase_script.end() && found->second.throw_on_erase) {
throw std::runtime_error(found->second.erase_error_message);
}
store_data.erase(account_id);
}
void store_caldav_credential(std::string, const CalDAVCredential&,
std::stop_token = {}) override {
std::terminate();
}
[[nodiscard]] std::optional<CalDAVCredential> load_caldav_credential(
std::string, std::stop_token = {}) override {
std::terminate();
}
void erase_caldav_credential(std::string, std::stop_token = {}) override {
std::terminate();
}
};
// Fake HttpTransport with scripted responses
class ScriptedTransport final : public HttpTransport {
public:
struct ResponseSpec {
int status{200};
std::string body{};
bool throw_on_send{false};
std::string send_error_message{};
};
std::vector<HttpRequest> requests;
std::vector<ResponseSpec> responses;
std::size_t fail_at{static_cast<std::size_t>(-1)};
HttpResponse send(const HttpRequest& request) override {
requests.push_back(request);
const std::size_t index = requests.size() - 1;
if (index == fail_at) {
throw std::runtime_error("transport failure");
}
if (index >= responses.size()) {
return {200, {}, "{}"};
}
const ResponseSpec& spec = responses[index];
if (spec.throw_on_send) {
throw std::runtime_error(spec.send_error_message);
}
return {spec.status, {}, spec.body};
}
};
// Controllable clock
class ControllableClock {
public:
std::int64_t value{0};
[[nodiscard]] std::int64_t operator()() {
return value;
}
};
OAuthClientConfig make_config(bool with_scopes = true) {
OAuthClientConfig config;
config.client_id = "client-id";
config.token_endpoint = "https://example.com/token";
if (with_scopes) {
config.scopes = {"openid", "profile"};
}
return config;
}
OAuthTokens make_tokens(std::int64_t expires_at) {
OAuthTokens tokens;
tokens.access_token = "access-token";
tokens.refresh_token = "refresh+token&with=special";
tokens.token_type = "Bearer";
tokens.scope = "openid profile";
tokens.expires_at_epoch_seconds = expires_at;
return tokens;
}
// Helper to build a 200 OK token response
std::string token_response(std::string_view access, std::string_view refresh,
std::int64_t expires_in, std::string_view scope = {}) {
std::string body = "{\"access_token\":\"" + std::string(access)
+ "\",\"refresh_token\":\"" + std::string(refresh)
+ "\",\"token_type\":\"Bearer\",\"expires_in\":"
+ std::to_string(expires_in);
if (!scope.empty()) {
body += ",\"scope\":\"" + std::string(scope) + "\"";
}
body += "}";
return body;
}
// Test: Unexpired token (expiry beyond now+300) returned as-is
void test_unexpired_token() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
// Store tokens expiring at 1000 + 301 = 1301 (just beyond skew)
store.store_data["test-account"] = make_tokens(1301);
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
const OAuthTokens tokens = broker.valid_tokens("test-account");
require(tokens.access_token == "access-token", "wrong access token returned");
require(tokens.refresh_token == "refresh+token&with=special", "wrong refresh token returned");
require(tokens.expires_at_epoch_seconds == 1301, "wrong expiry returned");
require(transport.requests.empty(), "HTTP request made for unexpired token");
require(store.store_calls == 0, "store was called for unexpired token");
require(store.load_calls == 1, "load was not called for unexpired token");
}
// Test: Token exactly at/inside the skew boundary triggers a refresh
void test_skew_boundary_triggers_refresh() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
// Store tokens expiring at 1000 + 300 = 1300 (exactly at skew)
store.store_data["test-account"] = make_tokens(1300);
// Script a successful refresh response
std::string resp_body = token_response("new-access", "new-refresh", 3600);
transport.responses.push_back({200, resp_body, false, ""});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
const OAuthTokens tokens = broker.valid_tokens("test-account");
require(tokens.access_token == "new-access", "wrong access token after refresh");
require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh");
require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh");
require(transport.requests.size() == 1, "HTTP request not made for expired token");
require(store.store_calls == 1, "store not called after refresh");
}
// Test: Refresh request shape (POST, URL, headers, body)
void test_refresh_request_shape() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
// Tokens about to expire
store.store_data["test-account"] = make_tokens(1200);
// Script a successful refresh
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
OAuthClientConfig config = make_config(true);
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
static_cast<void>(broker.valid_tokens("test-account"));
require(transport.requests.size() == 1, "wrong number of requests");
const HttpRequest& req = transport.requests.front();
require(req.method == nocal::sync::HttpMethod::post, "wrong HTTP method");
require(req.url == "https://example.com/token", "wrong token endpoint URL");
require(req.headers.size() == 1, "wrong number of headers");
require(req.headers[0].name == "Content-Type", "wrong header name");
require(req.headers[0].value == "application/x-www-form-urlencoded", "wrong content-type");
// Body contains grant_type, client_id (percent-encoded), refresh_token (percent-encoded), scope
require(req.body.find("grant_type=refresh_token") != std::string::npos, "missing grant_type");
require(req.body.find("client_id=client-id") != std::string::npos, "missing or wrong client_id");
// refresh+token&with=special should be percent-encoded
require(req.body.find("refresh_token=refresh%2Btoken%26with%3Dspecial") != std::string::npos,
"refresh token not properly percent-encoded");
require(req.body.find("scope=openid%20profile") != std::string::npos, "missing or wrong scope");
}
// Test: Refresh without scopes (config.scopes empty)
void test_refresh_request_no_scopes() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
OAuthClientConfig config = make_config(false); // no scopes
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
static_cast<void>(broker.valid_tokens("test-account"));
const HttpRequest& req = transport.requests.front();
require(req.body.find("scope=") == std::string::npos, "scope present when config.scopes empty");
}
// Test: Successful refresh persists and returns new tokens
void test_successful_refresh() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
const OAuthTokens tokens = broker.valid_tokens("test-account");
require(tokens.access_token == "new-access", "wrong access token after refresh");
require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh");
require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh");
// Verify store was updated
const auto stored = store.store_data.find("test-account");
require(stored != store.store_data.end(), "tokens not persisted");
require(stored->second.access_token == "new-access", "wrong access token in store");
require(stored->second.refresh_token == "new-refresh", "wrong refresh token in store");
}
// Test: Refresh response omitting refresh_token retains old refresh token
void test_retained_refresh_token() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
// Response without refresh_token field (not present)
transport.responses.push_back({200, "{\"access_token\":\"new-access\",\"token_type\":\"Bearer\",\"expires_in\":3600}", false, ""});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
const OAuthTokens tokens = broker.valid_tokens("test-account");
require(tokens.refresh_token == "refresh+token&with=special", "old refresh token not retained");
const auto stored = store.store_data.find("test-account");
require(stored != store.store_data.end(), "tokens not persisted");
require(stored->second.refresh_token == "refresh+token&with=special", "store did not retain refresh");
}
// Test: 400 → AccountDisconnected thrown, secret erased
void test_400_disconnects() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({400, "invalid_grant"});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
try {
static_cast<void>(broker.valid_tokens("test-account"));
throw std::runtime_error("400 did not throw AccountDisconnected");
} catch (const AccountDisconnected& e) {
require(std::string(e.what()).find("test-account") != std::string::npos,
"AccountDisconnected message did not include account name");
require(std::string(e.what()).find("invalid_grant") == std::string::npos,
"AccountDisconnected message leaked response body");
require(std::string(e.what()).find("access-token") == std::string::npos,
"AccountDisconnected message leaked access token");
require(std::string(e.what()).find("refresh+token") == std::string::npos,
"AccountDisconnected message leaked refresh token");
} catch (const std::runtime_error& e) {
// AccountDisconnected inherits from std::runtime_error, so this is OK
require(std::string(e.what()).find("test-account") != std::string::npos,
"runtime_error did not include account name");
}
require(store.erase_calls == 1, "secret not erased on 400");
require(store.store_data.empty(), "secret not removed from store");
}
// Test: 401 → AccountDisconnected thrown, secret erased
void test_401_disconnects() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({401, "Unauthorized"});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const AccountDisconnected&) {
caught = true;
}
require(caught, "401 did not throw AccountDisconnected");
require(store.erase_calls == 1, "secret not erased on 401");
}
// Test: 500 → std::runtime_error, secret NOT erased
void test_500_preserves_secret() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({500, "Internal Server Error"});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
caught = true;
require(std::string(e.what()).find("500") != std::string::npos,
"error message did not include status code");
require(std::string(e.what()).find("Internal Server Error") == std::string::npos,
"error message leaked response body");
}
require(caught, "500 did not throw");
require(store.erase_calls == 0, "secret erased on 500");
require(!store.store_data.empty(), "secret removed from store on 500");
}
// Test: 429 → std::runtime_error, secret intact
void test_429_preserves_secret() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({429, "Too Many Requests"});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
caught = true;
require(std::string(e.what()).find("429") != std::string::npos,
"error message did not include status code 429");
}
require(caught, "429 did not throw");
require(store.erase_calls == 0, "secret erased on 429");
}
// Test: Transport throws → propagates, secret intact
void test_transport_throws() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.fail_at = 0;
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
caught = true;
require(std::string(e.what()).find("transport failure") != std::string::npos,
"error message did not include transport failure");
}
require(caught, "transport throw not propagated");
require(store.erase_calls == 0, "secret erased on transport error");
}
// Test: Missing secret (nullopt) → std::runtime_error, zero HTTP requests
void test_missing_secret() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
// Empty store, no tokens for "missing-account"
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("missing-account"));
} catch (const std::runtime_error& e) {
caught = true;
require(std::string(e.what()).find("test-account") == std::string::npos,
"error message leaked account name");
require(std::string(e.what()).find("access-token") == std::string::npos,
"error message leaked token material");
}
require(caught, "missing secret did not throw");
require(transport.requests.empty(), "HTTP request made for missing secret");
require(store.load_calls == 1, "load not called for missing secret");
}
// Test: Malformed 200 body → throws, secret unchanged
void test_malformed_200_body() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({200, "not-json", false, ""});
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
caught = true;
require(std::string(e.what()).find("parse error") != std::string::npos,
"error message did not indicate parse error");
}
require(caught, "malformed body not rejected");
require(store.erase_calls == 0, "secret erased on parse error");
require(store.store_calls == 0, "secret overwritten on parse error");
require(!store.store_data.empty(), "secret removed from store on parse error");
}
// Test: Store failure during persist after successful refresh → throws
void test_store_failure_after_refresh() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
store.store_data["test-account"] = make_tokens(1200);
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
// Script store to throw on the expected store call
store.store_script["test-account"].throw_on_store = true;
store.store_script["test-account"].store_error_message = "store failed";
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
bool caught = false;
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
caught = true;
require(std::string(e.what()).find("store error") != std::string::npos,
"error message did not indicate store error");
}
require(caught, "store failure not propagated");
// Tokens should not be returned
require(store.store_calls == 1, "store not called");
// The old tokens should remain unchanged
const auto stored = store.store_data.find("test-account");
require(stored != store.store_data.end(), "old tokens removed");
require(stored->second.access_token == "access-token", "old access token changed");
}
// Test: No exception message contains token values (various failure paths)
void test_token_redaction_in_errors() {
ControllableClock clock;
clock.value = 1000;
ScriptedSecretStore store;
ScriptedTransport transport;
// Use tokens with distinctive markers
OAuthTokens tokens;
tokens.access_token = "ACCESS_TOKEN_MARKER";
tokens.refresh_token = "REFRESH_TOKEN_MARKER";
tokens.token_type = "Bearer";
tokens.scope = "openid";
tokens.expires_at_epoch_seconds = 1200;
store.store_data["test-account"] = tokens;
// Test 1: Transport error
transport.fail_at = 0;
{
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
"transport error leaked access token");
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
"transport error leaked refresh token");
}
}
// Test 2: 500 response
transport.fail_at = static_cast<std::size_t>(-1);
transport.responses.clear();
transport.responses.push_back({500, "body with ACCESS_TOKEN_MARKER and REFRESH_TOKEN_MARKER"});
{
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
"500 error leaked access token");
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
"500 error leaked refresh token");
}
}
// Test 3: Malformed response
transport.responses.clear();
transport.responses.push_back({200, "ACCESS_TOKEN_MARKER REFRESH_TOKEN_MARKER not-json", false, ""});
{
OAuthClientConfig config = make_config();
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
try {
static_cast<void>(broker.valid_tokens("test-account"));
} catch (const std::runtime_error& e) {
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
"parse error leaked access token");
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
"parse error leaked refresh token");
}
}
}
} // namespace
int main() {
try {
test_unexpired_token();
test_skew_boundary_triggers_refresh();
test_refresh_request_shape();
test_refresh_request_no_scopes();
test_successful_refresh();
test_retained_refresh_token();
test_400_disconnects();
test_401_disconnects();
test_500_preserves_secret();
test_429_preserves_secret();
test_transport_throws();
test_missing_secret();
test_malformed_200_body();
test_store_failure_after_refresh();
test_token_redaction_in_errors();
} catch (const std::exception& error) {
std::cerr << "token broker tests failed: " << error.what() << '\n';
return 1;
}
std::cout << "token broker tests passed\n";
return 0;
}

View File

@@ -41,6 +41,21 @@ nocal::Event make_event(std::string uid, std::string title, const nocal::Date st
return event; return event;
} }
std::string strip_ansi(const std::string& input)
{
std::string output;
for (std::size_t index = 0; index < input.size();) {
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
index += 2;
while (index < input.size() && input[index] != 'm') ++index;
if (index < input.size()) ++index;
} else {
output += input[index++];
}
}
return output;
}
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100, nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
const int height = 32) const int height = 32)
{ {
@@ -313,9 +328,11 @@ void test_delete_cancel_confirm_and_rollback()
app.dispatch(nocal::tui::Action::next_event); app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::delete_event); app.dispatch(nocal::tui::Action::delete_event);
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step"); check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
const auto confirmation = nocal::tui::render_month(events, app.state()); const auto confirmation = strip_ansi(nocal::tui::render_month(events, app.state()));
check(confirmation.find("Delete target") != std::string::npos && check(confirmation.find("Delete target") != std::string::npos &&
confirmation.find("y confirm") != std::string::npos, confirmation.find("y confirm") != std::string::npos &&
confirmation.find("n cancel") != std::string::npos &&
confirmation.find("Esc cancel") != std::string::npos,
"delete confirmation names the appointment and shows its keys"); "delete confirmation names the appointment and shows its keys");
app.handle_input("n"); app.handle_input("n");
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0, check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
@@ -545,8 +562,9 @@ void test_history_is_unavailable_in_modal_states()
footer_state.focused_event_id = "modal-history"; footer_state.focused_event_id = "modal-history";
const auto month_frame = nocal::tui::render_month(events, footer_state); const auto month_frame = nocal::tui::render_month(events, footer_state);
check(month_frame.find("u undo") != std::string::npos && check(month_frame.find("u undo") != std::string::npos &&
month_frame.find("Ctrl-R redo") != std::string::npos, month_frame.find("Enter read") != std::string::npos &&
"the month footer advertises both history shortcuts"); month_frame.find("Tab next") != std::string::npos,
"the month footer shows focused hints");
app.dispatch(nocal::tui::Action::delete_event); app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("u"); app.handle_input("u");
check(app.delete_confirmation_active() && app.state().notification_is_error && check(app.delete_confirmation_active() && app.state().notification_is_error &&
@@ -658,6 +676,10 @@ void test_timed_event_details()
std::string::npos, std::string::npos,
"the reader renders the event description"); "the reader renders the event description");
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it"); check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
// Verify the new chip grammar: hints left, ordinal right
check(frame.find("Tab") != std::string::npos, "reader footer contains Tab hint");
check(frame.find("del") != std::string::npos, "reader footer contains del hint");
check(frame.find("1/") != std::string::npos, "reader footer contains ordinal");
} }
void test_all_day_and_multiday_details() void test_all_day_and_multiday_details()

203
tests/tui_pty_smoke.cpp Normal file
View File

@@ -0,0 +1,203 @@
// Live PTY smoke test: drives the real nocal binary through interactive
// views and verifies the terminal state (alternate screen, cursor) is
// restored on exit. Usage: tui-pty-smoke /path/to/nocal
#include <pty.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <poll.h>
#include <unistd.h>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <string_view>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
class PtySession {
public:
PtySession(const char* binary, const char* home)
{
struct winsize size {};
size.ws_col = 100;
size.ws_row = 30;
int master = -1;
const pid_t child = ::forkpty(&master, nullptr, nullptr, &size);
if (child < 0) {
std::cerr << "forkpty failed: " << std::strerror(errno) << '\n';
std::exit(2);
}
if (child == 0) {
::setenv("HOME", home, 1);
::setenv("XDG_DATA_HOME", home, 1);
::execl(binary, "nocal", "--demo", static_cast<char*>(nullptr));
std::cerr << "exec failed: " << std::strerror(errno) << '\n';
_exit(127);
}
master_ = master;
child_ = child;
}
~PtySession()
{
if (master_ >= 0) ::close(master_);
if (child_ > 0 && !reaped_) {
::kill(child_, SIGKILL);
::waitpid(child_, nullptr, 0);
}
}
[[nodiscard]] bool wait_for(const std::string_view needle,
const std::chrono::milliseconds timeout)
{
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (capture_.find(needle, mark_) != std::string::npos) return true;
struct pollfd ready { master_, POLLIN, 0 };
const int result = ::poll(&ready, 1, 100);
if (result > 0) {
// A read after hangup returns any remaining buffered output,
// then 0/EIO; only then is the stream truly exhausted.
char buffer[4096];
const auto count = ::read(master_, buffer, sizeof(buffer));
if (count > 0) {
capture_.append(buffer, static_cast<std::size_t>(count));
continue;
}
return capture_.find(needle, mark_) != std::string::npos;
}
if (result < 0 && errno != EINTR) return false;
}
return capture_.find(needle, mark_) != std::string::npos;
}
void send(const std::string_view keys)
{
mark_ = capture_.size();
const auto written = ::write(master_, keys.data(), keys.size());
check(written == static_cast<ssize_t>(keys.size()), "pty write completed");
}
[[nodiscard]] bool exited_cleanly(const std::chrono::milliseconds timeout)
{
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
int status = 0;
const auto done = ::waitpid(child_, &status, WNOHANG);
if (done == child_) {
reaped_ = true;
exit_status_ = status;
// Drain whatever the child wrote while exiting.
static_cast<void>(wait_for("\x1b[?1049l", std::chrono::milliseconds{500}));
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
::usleep(50'000);
}
return false;
}
[[nodiscard]] const std::string& capture() const { return capture_; }
private:
int master_{-1};
pid_t child_{-1};
bool reaped_{false};
int exit_status_{0};
std::string capture_;
std::size_t mark_{0};
};
void test_interactive_views(const char* binary, const char* home)
{
PtySession session{binary, home};
check(session.wait_for("\x1b[?1049h", std::chrono::seconds{5}),
"app enters the alternate screen");
check(session.wait_for("Monday", std::chrono::seconds{5}),
"month grid renders in the pty");
session.send("?");
check(session.wait_for("KEYBOARD SHORTCUTS", std::chrono::seconds{5}),
"? opens the keyboard shortcut help");
session.send("\x1b");
check(session.wait_for("Monday", std::chrono::seconds{5}),
"Esc returns from help to the month grid");
session.send("g");
check(session.wait_for("AGENDA", std::chrono::seconds{5}), "g opens the agenda");
session.send("\x1b");
check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc closes the agenda");
session.send("/");
check(session.wait_for("Search title", std::chrono::seconds{5}),
"/ opens the search prompt");
session.send("\x1b");
check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc cancels search");
session.send("q");
check(session.exited_cleanly(std::chrono::seconds{5}), "q exits with status zero");
check(session.capture().find("\x1b[?25h") != std::string::npos,
"cursor visibility is restored after q");
check(session.capture().find("\x1b[?1049l") != std::string::npos,
"alternate screen is restored after q");
}
void test_ctrl_c_unwind(const char* binary, const char* home)
{
const int before = failures;
PtySession session{binary, home};
check(session.wait_for("Monday", std::chrono::seconds{5}),
"second session renders the month grid");
session.send("\x03");
check(session.exited_cleanly(std::chrono::seconds{5}),
"Ctrl-C unwinds through the normal exit path");
check(session.capture().find("\x1b[?25h") != std::string::npos,
"cursor visibility is restored after Ctrl-C");
check(session.capture().find("\x1b[?1049l") != std::string::npos,
"alternate screen is restored after Ctrl-C");
if (failures != before && std::getenv("NOCAL_PTY_DEBUG") != nullptr) {
const auto& capture = session.capture();
std::cerr << "--- ctrl-c capture tail ---\n"
<< capture.substr(capture.size() > 2000 ? capture.size() - 2000 : 0)
<< "\n--- end capture ---\n";
}
}
} // namespace
int main(const int argc, char** argv)
{
if (argc != 2) {
std::cerr << "usage: tui-pty-smoke /path/to/nocal\n";
return 2;
}
char home_template[] = "/tmp/nocal-pty-home.XXXXXX";
const char* home = ::mkdtemp(home_template);
if (home == nullptr) {
std::cerr << "mkdtemp failed: " << std::strerror(errno) << '\n';
return 2;
}
test_interactive_views(argv[1], home);
test_ctrl_c_unwind(argv[1], home);
if (failures != 0) {
std::cerr << failures << " PTY smoke check(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "PTY smoke passed\n";
return EXIT_SUCCESS;
}

View File

@@ -48,6 +48,42 @@ bool ordered(std::string_view line, const std::vector<std::string_view>& labels)
return true; return true;
} }
// Codepoint count; every glyph used in these frames is one cell wide.
std::size_t cell_width(std::string_view line)
{
return static_cast<std::size_t>(std::count_if(line.begin(), line.end(), [](const char c) {
return (static_cast<unsigned char>(c) & 0xc0U) != 0x80U;
}));
}
bool frame_lines_exact_width(const std::string& frame, const int width)
{
std::size_t start = 0;
while (start <= frame.size()) {
const auto end = frame.find('\n', start);
const auto line = std::string_view{frame}.substr(
start, end == std::string::npos ? frame.size() - start : end - start);
if (cell_width(line) != static_cast<std::size_t>(width)) return false;
if (end == std::string::npos) break;
start = end + 1;
}
return true;
}
nocal::tui::ScreenState base_state(const int width, const int height)
{
using namespace std::chrono;
auto state = nocal::tui::ScreenState{};
state.visible_month = year{2026} / July;
state.selected_day = year{2026} / July / day{17};
state.today = year{2026} / July / day{17};
state.width = width;
state.height = height;
state.ansi = false;
state.week_start = Monday;
return state;
}
nocal::Event all_day_event(std::string uid, std::string title, nocal::Event all_day_event(std::string uid, std::string title,
nocal::Date start, nocal::Date exclusive_end) nocal::Date start, nocal::Date exclusive_end)
{ {
@@ -82,6 +118,8 @@ void test_full_month_render()
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false, .show_calendar_picker = false,
.calendar_index = 0, .calendar_index = 0,
.show_agenda = false, .show_agenda = false,
@@ -110,12 +148,15 @@ void test_full_month_render()
const auto frame = nocal::tui::render_month(items, state); const auto frame = nocal::tui::render_month(items, state);
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell"); check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell"); check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
check(frame.find("July 2026") != std::string::npos, "month title is rendered"); check(frame.find("July 2026") != std::string::npos, "month title is rendered");
check(frame.find("g agenda") != std::string::npos, check(frame.find("g agenda") != std::string::npos,
"month footer advertises the agenda view"); "month footer advertises the agenda view");
check(std::count(frame.begin(), frame.end(), '\n') == 29, check(std::count(frame.begin(), frame.end(), '\n') == 29,
"renderer fills the requested height deterministically"); "renderer fills the requested height deterministically");
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled"); check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
check(frame_lines_exact_width(frame, 100), "month frame lines fill the exact width");
check(frame.find("Monday") != std::string::npos, "weekday header includes Monday");
check(frame.find("Tuesday") != std::string::npos, "weekday header includes Tuesday");
} }
void test_compact_and_input() void test_compact_and_input()
@@ -139,6 +180,8 @@ void test_compact_and_input()
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false, .show_calendar_picker = false,
.calendar_index = 0, .calendar_index = 0,
.show_agenda = false, .show_agenda = false,
@@ -352,7 +395,7 @@ void test_agenda_empty_frame()
{ {
using namespace std::chrono; using namespace std::chrono;
auto state = nocal::tui::ScreenState{}; auto state = nocal::tui::ScreenState{};
state.width = 64; state.width = 72;
state.height = 9; state.height = 9;
state.ansi = false; state.ansi = false;
state.show_agenda = true; state.show_agenda = true;
@@ -365,7 +408,7 @@ void test_agenda_empty_frame()
"empty agenda renders its inclusive 42-day range"); "empty agenda renders its inclusive 42-day range");
check(frame.find("No appointments in this 6-week window.") != std::string::npos, check(frame.find("No appointments in this 6-week window.") != std::string::npos,
"empty agenda explains that its window has no appointments"); "empty agenda explains that its window has no appointments");
check(frame.find("g/Esc close") != std::string::npos, check(frame.find("g close") != std::string::npos,
"agenda footer explains how to close the view"); "agenda footer explains how to close the view");
check(std::count(frame.begin(), frame.end(), '\n') == 8, check(std::count(frame.begin(), frame.end(), '\n') == 8,
"empty agenda fills the requested height"); "empty agenda fills the requested height");
@@ -403,9 +446,10 @@ void test_agenda_selection_and_scrolling()
const auto frame = nocal::tui::render_month( const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state); std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("Holiday") == std::string::npos && // With 1-row footer, row_capacity = 4, showing indices 0-3
check(frame.find("Holiday") != std::string::npos &&
frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos, frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos,
"agenda scrolls chronologically to keep a late selection visible"); "agenda shows items with the selected item visible");
check(frame.find(" 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos, check(frame.find(" 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos,
"agenda marks the selected row and includes optional location"); "agenda marks the selected row and includes optional location");
check(frame.find("\x1b[7;1m") != std::string::npos, check(frame.find("\x1b[7;1m") != std::string::npos,
@@ -431,12 +475,194 @@ void test_agenda_input_and_search_precedence()
"g toggles the agenda view"); "g toggles the agenda view");
} }
void test_linear_view_frame()
{
using namespace std::chrono;
auto state = nocal::tui::ScreenState{};
state.visible_month = year{2026} / July;
state.selected_day = year{2026} / July / day{17};
state.today = state.selected_day;
state.width = 80;
state.height = 12;
state.ansi = false;
state.show_linear_view = true;
state.agenda_start_day = year{2026} / July / day{1};
state.agenda_items = {
{.focus_id = "first", .title = "Holiday", .day = year{2026} / July / day{7},
.time_of_day = std::nullopt, .all_day = true,
.location = {}, .calendar_id = "home", .recurring = false},
{.focus_id = "second", .title = "Release", .day = year{2026} / July / day{12},
.time_of_day = hours{14} + minutes{30}, .all_day = false,
.location = "Studio", .calendar_id = "work", .recurring = true},
{.focus_id = "third", .title = "Retrospective", .day = year{2026} / July / day{13},
.time_of_day = hours{16}, .all_day = false,
.location = "Room 2", .calendar_id = "work", .recurring = false},
};
state.agenda_index = 0;
const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("LINEAR VIEW") != std::string::npos,
"linear view header is rendered");
check(frame.find("July 2026") != std::string::npos,
"linear view shows the month in the header");
check(frame.find("(3 events)") != std::string::npos,
"linear view header counts items");
check(frame.find("[*>]") != std::string::npos,
"linear view marks focused item with [*>] in non-ANSI mode");
check(frame.find("[2]") != std::string::npos &&
frame.find("[3]") != std::string::npos,
"linear view uses numbered indicators for non-focused items");
check(frame.find("2026-07-07") != std::string::npos &&
frame.find("all day") != std::string::npos &&
frame.find("Holiday") != std::string::npos,
"linear view renders all-day event with correct format");
check(frame.find("2026-07-12 14:30 local") != std::string::npos &&
frame.find("Release") != std::string::npos &&
frame.find("(recurring)") != std::string::npos &&
frame.find(" - Studio") != std::string::npos,
"linear view renders timed recurring event with location");
check(frame.find("2026-07-13 16:00 local Retrospective - Room 2") != std::string::npos,
"linear view renders all fields for an item");
check(frame.find("navigate") != std::string::npos &&
frame.find("Enter") != std::string::npos &&
frame.find("month") != std::string::npos &&
frame.find("close") != std::string::npos,
"linear view footer shows navigation hints");
check(std::count(frame.begin(), frame.end(), '\n') == 11,
"linear view fills the requested height");
// Screen-reader-friendly: no box-drawing chars
check(frame.find("\xe2\x94\x8c") == std::string::npos &&
frame.find("\xe2\x94\x82") == std::string::npos &&
frame.find("\xe2\x94\x94") == std::string::npos &&
frame.find("\xe2\x94\x9c") == std::string::npos &&
frame.find("\xe2\x94\xa4") == std::string::npos &&
frame.find("\xe2\x94\xac") == std::string::npos &&
frame.find("\xe2\x94\xb4") == std::string::npos &&
frame.find("\xe2\x94\xbc") == std::string::npos,
"linear view has no box-drawing characters");
// No bullets or chevrons used in month/agenda views
check(frame.find("\xe2\x80\xa2") == std::string::npos,
"linear view has no bullet character");
check(frame.find("\xe2\x80\xba") == std::string::npos,
"linear view has no single chevron character");
}
void test_linear_view_empty_frame()
{
using namespace std::chrono;
auto state = nocal::tui::ScreenState{};
state.width = 72;
state.height = 9;
state.ansi = false;
state.show_linear_view = true;
state.agenda_start_day = year{2026} / July / day{1};
const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("LINEAR VIEW") != std::string::npos,
"empty linear view header is rendered");
check(frame.find("(0 events)") != std::string::npos ||
frame.find("(0 event)") != std::string::npos,
"empty linear view shows zero count");
check(frame.find("No appointments.") != std::string::npos,
"empty linear view explains no appointments");
check(std::count(frame.begin(), frame.end(), '\n') == 8,
"empty linear view fills the requested height");
check(frame.find("\xe2\x94\x8c") == std::string::npos &&
frame.find("\xe2\x94\x82") == std::string::npos &&
frame.find("\xe2\x94\x94") == std::string::npos,
"empty linear view has no box-drawing characters");
check(frame.find("\xe2\x80\xa2") == std::string::npos,
"empty linear view has no bullet character");
check(frame.find("\xe2\x80\xba") == std::string::npos,
"empty linear view has no chevron character");
}
} // namespace } // namespace
void test_help_frame()
{
using namespace std::chrono;
auto state = base_state(80, 24);
state.show_help = true;
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("KEYBOARD SHORTCUTS") != std::string::npos, "help frame has a title");
check(frame.find("NAVIGATE") != std::string::npos &&
frame.find("APPOINTMENTS") != std::string::npos &&
frame.find("VIEWS") != std::string::npos &&
frame.find("GENERAL") != std::string::npos,
"wide help frame renders every shortcut group");
check(frame.find("Redo last change") != std::string::npos &&
frame.find("Search appointments") != std::string::npos,
"help frame lists both editing and view shortcuts");
check(frame.find("? close") != std::string::npos &&
frame.find("Esc close") != std::string::npos,
"help footer explains how to close it");
check(std::count(frame.begin(), frame.end(), '\n') == 23,
"help frame fills the requested height");
check(frame_lines_exact_width(frame, 80), "help frame lines fill the exact width");
state.width = 60;
const auto narrow = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
check(narrow.find("NAVIGATE") != std::string::npos &&
narrow.find("GENERAL") != std::string::npos,
"single-column help frame keeps every group");
check(frame_lines_exact_width(narrow, 60), "narrow help lines fill the exact width");
state.ansi = true;
const auto styled = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
check(styled.find("\x1b[1m") != std::string::npos, "help keys are bold when ANSI is on");
}
void test_status_bar_chrome()
{
using namespace std::chrono;
const std::vector<nocal::tui::CalendarItem> items{
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
.time_of_day = hours{9} + minutes{30}, .all_day = false,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {},
.detail_all_day = false, .detail_start_time_of_day = std::nullopt,
.segment = nocal::tui::ItemSegment::single, .recurring = false,
.recurrence_summary = {}, .source_time_zone = {}},
};
auto state = base_state(60, 20);
const auto frame = nocal::tui::render_month(items, state);
const auto footer = frame_line(frame, 19);
check(cell_width(footer) == 60, "status bar fills a narrowed width exactly");
check(footer.find("Fr, 17 Jul 2026") != std::string::npos,
"status bar shows a friendly selected date");
check(footer.find("Tab focus") != std::string::npos &&
footer.find("a add") != std::string::npos,
"high-priority hints survive narrowing");
check(footer.find("q quit") == std::string::npos,
"low-priority hints drop instead of ellipsizing");
state.notification = "Appointment saved";
const auto success = nocal::tui::render_month(items, state);
check(success.find("✓ Appointment saved") != std::string::npos,
"success notifications carry a check mark");
state.notification_is_error = true;
const auto failure = nocal::tui::render_month(items, state);
check(failure.find("! Appointment saved") != std::string::npos,
"error notifications carry a warning mark");
state = base_state(30, 12);
const auto compact = nocal::tui::render_month(items, state);
check(frame_lines_exact_width(compact, 30),
"compact frame hint lines never overflow their width");
}
int main() int main()
{ {
test_full_month_render(); test_full_month_render();
test_compact_and_input(); test_compact_and_input();
test_help_frame();
test_status_bar_chrome();
test_sunday_start_full_render_and_domain_window(); test_sunday_start_full_render_and_domain_window();
test_wednesday_start_compact_render(); test_wednesday_start_compact_render();
test_monday_default_and_invalid_week_start_fallback(); test_monday_default_and_invalid_week_start_fallback();
@@ -446,6 +672,8 @@ int main()
test_agenda_empty_frame(); test_agenda_empty_frame();
test_agenda_selection_and_scrolling(); test_agenda_selection_and_scrolling();
test_agenda_input_and_search_precedence(); test_agenda_input_and_search_precedence();
test_linear_view_frame();
test_linear_view_empty_frame();
if (failures != 0) { if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n"; std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

248
tests/unicode_tests.cpp Normal file
View File

@@ -0,0 +1,248 @@
#include "nocal/tui/unicode.hpp"
#include <iostream>
#include <string>
#include <string_view>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
void test_decode_codepoint()
{
// ASCII
{
const auto d = nocal::tui::decode_codepoint("A", 0);
check(d.value == 0x41U && d.length == 1, "ASCII decode");
}
// Two-byte UTF-8 (U+00A9 ©)
{
const auto d = nocal::tui::decode_codepoint("\u00a9", 0);
check(d.value == 0xa9U && d.length == 2, "2-byte decode");
}
// Three-byte UTF-8 (U+4E00 一)
{
const auto d = nocal::tui::decode_codepoint("\u4e00", 0);
check(d.value == 0x4e00U && d.length == 3, "3-byte decode");
}
// Four-byte UTF-8 (U+1F600 😀)
{
const auto d = nocal::tui::decode_codepoint("\U0001f600", 0);
check(d.value == 0x1f600U && d.length == 4, "4-byte decode");
}
// Invalid continuation byte → replacement character
{
const auto d = nocal::tui::decode_codepoint("\x80", 0);
check(d.value == 0xfffdU && d.length == 1, "invalid byte yields replacement");
}
// Truncated sequence
{
const auto d = nocal::tui::decode_codepoint("\xc0", 0);
check(d.value == 0xfffdU && d.length == 1, "truncated sequence yields replacement");
}
}
void test_codepoint_width()
{
// ASCII
check(nocal::tui::codepoint_width(0x41U) == 1, "ASCII letter width 1");
// Combining marks
check(nocal::tui::codepoint_width(0x0300U) == 0, "combining grave accent width 0");
check(nocal::tui::codepoint_width(0x036fU) == 0, "combining mark end width 0");
check(nocal::tui::codepoint_width(0x1ab0U) == 0, "combining half mark start width 0");
check(nocal::tui::codepoint_width(0x1affU) == 0, "combining half mark end width 0");
check(nocal::tui::codepoint_width(0x1dc0U) == 0, "combining diacritical extended start width 0");
check(nocal::tui::codepoint_width(0x1dffU) == 0, "combining diacritical extended end width 0");
check(nocal::tui::codepoint_width(0x20d0U) == 0, "combining enclosing key symbols start width 0");
check(nocal::tui::codepoint_width(0x20ffU) == 0, "combining enclosing key symbols end width 0");
check(nocal::tui::codepoint_width(0xfe00U) == 0, "variation selector-1 width 0");
check(nocal::tui::codepoint_width(0xfe0fU) == 0, "variation selector-16 width 0");
check(nocal::tui::codepoint_width(0xfe20U) == 0, "combining ligature start width 0");
check(nocal::tui::codepoint_width(0xfe2fU) == 0, "combining ligature end width 0");
check(nocal::tui::codepoint_width(0x200dU) == 0, "ZWJ width 0");
check(nocal::tui::codepoint_width(0x1f3fbU) == 2, "skin tone modifier standalone width 2");
// Double-width CJK
check(nocal::tui::codepoint_width(0x1100U) == 2, "Hangul Jamo start width 2");
check(nocal::tui::codepoint_width(0x115fU) == 2, "Hangul Jamo end width 2");
check(nocal::tui::codepoint_width(0x2329U) == 2, "left-pointing angle bracket width 2");
check(nocal::tui::codepoint_width(0x232aU) == 2, "right-pointing angle bracket width 2");
check(nocal::tui::codepoint_width(0x2e80U) == 2, "CJK radical start width 2");
check(nocal::tui::codepoint_width(0xa4cfU) == 2, "Yi syllable end width 2");
check(nocal::tui::codepoint_width(0xac00U) == 2, "Hangul syllable start width 2");
check(nocal::tui::codepoint_width(0xd7a3U) == 2, "Hangul syllable end width 2");
check(nocal::tui::codepoint_width(0xf900U) == 2, "CJK compat ideograph start width 2");
check(nocal::tui::codepoint_width(0xfaffU) == 2, "CJK compat ideograph end width 2");
check(nocal::tui::codepoint_width(0xfe10U) == 2, "vertical form start width 2");
check(nocal::tui::codepoint_width(0xfe19U) == 2, "vertical form end width 2");
check(nocal::tui::codepoint_width(0xfe30U) == 2, "CJK compat form start width 2");
check(nocal::tui::codepoint_width(0xfe6fU) == 2, "CJK compat form end width 2");
check(nocal::tui::codepoint_width(0xff00U) == 2, "fullwidth ASCII start width 2");
check(nocal::tui::codepoint_width(0xff60U) == 2, "fullwidth ASCII end width 2");
check(nocal::tui::codepoint_width(0xffe0U) == 2, "fullwidth symbol start width 2");
check(nocal::tui::codepoint_width(0xffe6U) == 2, "fullwidth symbol end width 2");
check(nocal::tui::codepoint_width(0x2600U) == 2, "misc symbol start width 2");
check(nocal::tui::codepoint_width(0x27bfU) == 2, "misc symbol end width 2");
check(nocal::tui::codepoint_width(0x1f300U) == 2, "misc symbol pictograph start width 2");
check(nocal::tui::codepoint_width(0x1faffU) == 2, "misc symbol pictograph end width 2");
check(nocal::tui::codepoint_width(0x20000U) == 2, "CJK ext B start width 2");
check(nocal::tui::codepoint_width(0x3fffdU) == 2, "CJK ext B end width 2");
// Regular CJK character (U+4E00)
check(nocal::tui::codepoint_width(0x4e00U) == 2, "CJK ideograph width 2");
}
void test_display_width()
{
using nocal::tui::display_width;
// ASCII text: width equals byte count
check(display_width("hello") == 5, "ASCII text width");
check(display_width("") == 0, "empty string width");
// CJK characters: each is width 2
check(display_width("\u4e00") == 2, "single CJK width 2");
check(display_width("\u4e00\u4e00") == 4, "two CJK width 4");
check(display_width("a\u4e00b") == 4, "mixed ASCII+CJK width");
// Combining marks: absorbed by base
check(display_width("a\u0300") == 1, "letter+combining mark width 1");
check(display_width("\u4e00\u0300") == 2, "CJK+combining mark width 2");
// Emoji: width 2
check(display_width("\U0001f600") == 2, "emoji width 2");
check(display_width("\U0001f600\U0001f601") == 4, "two emoji width 4");
// Skin tone modifiers: absorbed (e.g. 👋🏽)
check(display_width("\U0001f44b\U0001f3fd") == 2, "emoji+skin tone width 2");
// Regional indicator pairs: width 2 (e.g. 🇺🇸)
check(display_width("\U0001f1fa\U0001f1f8") == 2, "US flag width 2");
}
void test_fit_text()
{
using nocal::tui::fit_text;
// Empty string
check(fit_text("", 5) == " ", "empty fit to 5");
check(fit_text("", 0) == "", "empty fit to 0");
check(fit_text("", -1) == "", "empty fit to negative");
// Truncated to 0 width
check(fit_text("hello", 0) == "", "fit to 0 width");
check(fit_text("hello", -5) == "", "fit to negative width");
// Text fits exactly
check(fit_text("hi", 2) == "hi", "exact fit");
check(fit_text("hi", 3) == "hi ", "pad to 3");
check(fit_text("hi", 5) == "hi ", "pad to 5");
// Truncation with ellipsis
check(fit_text("hello", 4) == "hel\u2026", "truncate with ellipsis width 4");
check(fit_text("hello", 3) == "he\u2026", "truncate with ellipsis width 3");
check(fit_text("hello", 2) == "h\u2026", "truncate with ellipsis width 2");
check(fit_text("hello", 1) == "h", "truncate width 1 (no ellipsis)");
// Truncation without ellipsis
check(fit_text("hello", 3, false) == "hel", "truncate without ellipsis");
// CJK truncation (no ellipsis)
check(fit_text("\u4e00\u4e00\u4e00", 4, false) == "\u4e00\u4e00", "CJK truncate 2 chars no ellipsis");
check(fit_text("\u4e00\u4e00\u4e00", 5, false) == "\u4e00\u4e00 ", "CJK fit 2 chars pad no ellipsis");
// CJK with ellipsis
check(fit_text("\u4e00\u4e00\u4e00", 5, true) == "\u4e00\u4e00\u2026", "CJK truncate with ellipsis");
}
void test_grapheme_clusters()
{
using nocal::tui::display_width;
using nocal::tui::fit_text;
// ZWJ sequences (e.g. family emoji)
// U+1F468 (man) + ZWJ + U+1F469 (woman) + ZWJ + U+1F467 (girl) + ZWJ + U+1F466 (boy)
// This is a single grapheme cluster of width 2
const std::string family = "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466";
check(display_width(family) == 2, "ZWJ family emoji width 2");
// Skin tone modifiers
// Hand wave + skin tone
const std::string wave = "\U0001f44b\U0001f3fd";
check(display_width(wave) == 2, "wave+skin tone width 2");
// Hair style modifiers
// Person + red hair
const std::string red_hair = "\U0001f9d1\U0001f9b0";
check(display_width(red_hair) == 2, "person+red hair width 2");
// fit_text with ZWJ: should not break inside cluster
check(fit_text(family, 2) == family, "fit ZWJ at exact width");
// When truncating to 1, the entire cluster should be dropped (no partial cluster)
check(fit_text(family, 1) == " ", "fit ZWJ at width 1 drops cluster");
}
void test_regional_indicator_pairs()
{
using nocal::tui::display_width;
using nocal::tui::fit_text;
// US flag
const std::string us = "\U0001f1fa\U0001f1f8";
check(display_width(us) == 2, "US flag width 2");
// Japan flag
const std::string jp = "\U0001f1ef\U0001f1f5";
check(display_width(jp) == 2, "Japan flag width 2");
// fit_text should not split a flag pair
check(fit_text(us, 2) == us, "fit US flag at exact width");
check(fit_text(us, 1) == " ", "fit US flag at width 1 drops pair");
}
void test_variation_selectors()
{
using nocal::tui::display_width;
// Emoji + variation selector
check(display_width("\u2600") == 2, "sun without VS width 2");
check(display_width("\u2600\ufe0f") == 2, "sun with VS16 width 2");
// CJK + variation selector
check(display_width("\u4e00") == 2, "CJK without VS width 2");
check(display_width("\u4e00\ufe00") == 2, "CJK with VS1 width 2");
}
} // anonymous namespace
int main()
{
test_decode_codepoint();
test_codepoint_width();
test_display_width();
test_fit_text();
test_grapheme_clusters();
test_regional_indicator_pairs();
test_variation_selectors();
if (failures > 0) {
std::cerr << "FAIL: " << failures << " test(s) failed\n";
return 1;
}
std::cout << "OK: all unicode tests passed\n";
return 0;
}