Compare commits
18 Commits
98e07e287e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0563e2c162 | |||
| 5805a4f7a4 | |||
| 23d95e957c | |||
| 5efcfdafb9 | |||
| d2191094b8 | |||
| 1a69950f93 | |||
| 2dab5b97b6 | |||
| 3b73badf5d | |||
| 6c1f58ffb0 | |||
| 40fc4651cb | |||
| a159933baa | |||
|
|
673a2c66c9 | ||
| 725e48569e | |||
| 74c798e7aa | |||
| feec691535 | |||
| 5affa4d0cd | |||
| 6d9e7b0e3e | |||
| 23bcb20ab5 |
45
.opencode/agent/coder.md
Normal file
45
.opencode/agent/coder.md
Normal 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.
|
||||||
@@ -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:
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
296
docs/USAGE.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
21
include/nocal/sync/caldav_account.hpp
Normal file
21
include/nocal/sync/caldav_account.hpp
Normal 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
|
||||||
52
include/nocal/sync/caldav_sync.hpp
Normal file
52
include/nocal/sync/caldav_sync.hpp
Normal 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
|
||||||
41
include/nocal/sync/caldav_sync_provider.hpp
Normal file
41
include/nocal/sync/caldav_sync_provider.hpp
Normal 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
|
||||||
38
include/nocal/sync/caldav_xml.hpp
Normal file
38
include/nocal/sync/caldav_xml.hpp
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
70
include/nocal/sync/microsoft_account.hpp
Normal file
70
include/nocal/sync/microsoft_account.hpp
Normal 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
|
||||||
@@ -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);
|
||||||
|
|||||||
74
include/nocal/sync/microsoft_sync_provider.hpp
Normal file
74
include/nocal/sync/microsoft_sync_provider.hpp
Normal 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
|
||||||
56
include/nocal/sync/provider.hpp
Normal file
56
include/nocal/sync/provider.hpp
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
58
include/nocal/sync/token_broker.hpp
Normal file
58
include/nocal/sync/token_broker.hpp
Normal 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
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
35
include/nocal/tui/unicode.hpp
Normal file
35
include/nocal/tui/unicode.hpp
Normal 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
|
||||||
34
meson.build
34
meson.build
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
285
src/main.cpp
285
src/main.cpp
@@ -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';
|
||||||
|
|||||||
@@ -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() + "'");
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/sync/caldav_account.cpp
Normal file
42
src/sync/caldav_account.cpp
Normal 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
437
src/sync/caldav_sync.cpp
Normal 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
|
||||||
132
src/sync/caldav_sync_provider.cpp
Normal file
132
src/sync/caldav_sync_provider.cpp
Normal 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
262
src/sync/caldav_xml.cpp
Normal 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
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/sync/microsoft_account.cpp
Normal file
117
src/sync/microsoft_account.cpp
Normal 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
|
||||||
@@ -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 (...) {
|
||||||
|
|||||||
231
src/sync/microsoft_sync_provider.cpp
Normal file
231
src/sync/microsoft_sync_provider.cpp
Normal 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
|
||||||
@@ -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
147
src/sync/token_broker.cpp
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
270
src/tui/unicode.cpp
Normal 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 (0x1f3fb–0x1f3ff)
|
||||||
|
[[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 (0x1f9b0–0x1f9b3, 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 (0x1f1e6–0x1f1ff)
|
||||||
|
[[nodiscard]] bool is_regional_indicator(std::uint32_t cp) noexcept
|
||||||
|
{
|
||||||
|
return cp >= 0x1f1e6U && cp <= 0x1f1ffU;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a codepoint is a variation selector (0xfe00–0xfe0f)
|
||||||
|
[[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
206
tests/caldav_xml_tests.cpp
Normal 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><hello> & 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
899
tests/microsoft_account_tests.cpp
Normal file
899
tests/microsoft_account_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
718
tests/microsoft_sync_provider_tests.cpp
Normal file
718
tests/microsoft_sync_provider_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
655
tests/token_broker_tests.cpp
Normal file
655
tests/token_broker_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
203
tests/tui_pty_smoke.cpp
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
248
tests/unicode_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user