Bernardo Magri 98e07e287e feat(sync): reconcile secondary Graph calendars
Read documented Graph v1 calendarView pages for secondary calendars and publish each finite window only after the complete bounded listing validates.

Add schema-v2 per-window event membership so moved-out occurrences are evicted without being mislabeled as remote tombstones, while overlapping windows retain shared instances.

Verified: 18/18 fresh normal, 18/18 -Werror, and 18/18 ASan/UBSan tests.
2026-07-18 13:05:21 +01:00
2026-07-18 11:56:04 +01:00

Nocal

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 inherits its colors from your terminal theme.

This repository currently contains the first local-calendar vertical slice: date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms, a responsive ANSI terminal UI with month and agenda views, explicit safe calendar import/export, tests, and Linux launch integration. A provider-neutral SQLite cache now provides the durable boundary required before Office 365 or any other remote provider is connected. See the product specification, architecture, and roadmap.

Controls

Use arrow keys or h j k l to move, PageUp/PageDown or p/n to change month, and t for today. On a day with appointments, Tab and Shift-Tab move focus through them and Enter opens the appointment reader. In the reader, arrows/Vim keys or Tab variants browse the day's appointments and Esc returns to the month. Press g to open a read-only 42-day agenda starting on the selected date. Arrows, j/k, or Tab variants choose an occurrence; Enter returns to the month at that exact occurrence. PageUp/PageDown or p/n shift the agenda by 42 days, while Esc or g returns without changing 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 a to add an appointment. Focus an 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, Ctrl-S to save, and Esc to cancel. Back in the calendar, u undoes the last successful mutation and Ctrl-R redoes it. Press / to search titles, descriptions, locations, and calendar IDs. Search results use arrows or j/k; Enter jumps to the exact occurrence and Esc returns to the month. The calendar picker and search are also available over the agenda with c and /. Add, edit, and delete remain month-view workflows. Press ? for help and q to quit.

Build and run

nix-shell --run 'meson setup build && meson compile -C build'
./build/nocal --demo

Run the tests with meson test -C build --print-errorlogs. Nocal reads $XDG_DATA_HOME/nocal/calendar.ics by default (falling back to ~/.local/share/nocal/calendar.ics), or accepts another .ics path as its positional argument. --demo adds a few unsaved sample appointments and --print emits a non-interactive frame. Weeks start on Monday by default. Pass --week-start DAY, where DAY is exactly one of monday, tuesday, wednesday, thursday, friday, saturday, or sunday, to rotate the month grid and its weekday header. This is display-only: it does not change stored events, recurrence interpretation, agenda windows, or calendar interchange.

Calendar interchange is explicit, non-interactive, and network-free:

nocal --import SOURCE.ics --calendar TARGET.ics
nocal --export DESTINATION.ics --calendar SOURCE.ics

Import atomically merges source events into the target. An event whose UID and all fields exactly match an existing event is skipped. The complete operation is rejected before the target is written if either file is unsafe to round-trip or contains empty or duplicate UIDs or invalid intervals, or if an imported UID collides with a different target event. A successful merge that adds events retains the target's exact previous bytes as TARGET.ics.bak; an import containing only exact duplicates does not rewrite the target or its backup.

Export validates the source and atomically creates a canonical calendar at the destination. It refuses to overwrite any existing destination and rejects a source that fails event validation or that Nocal cannot safely round-trip. --import and --export are mutually exclusive and cannot be combined with --demo, --print, or --restore-backup.

Nocal expands and safely round-trips a focused iCalendar recurrence subset: daily, weekly, monthly, and yearly rules with interval, count or until, common weekly/monthly/yearly selectors, compatible DATE or DATE-TIME RDATE additions, EXDATE, floating times, UTC, and system IANA TZID names. Occurrences merge DTSTART, rule expansion, and RDATE, remove duplicate starts, and then apply EXDATE. Recurring instances are individually navigable; edit and delete clearly operate on the entire series.

Search expands recurring matches within five years on either side of the currently selected date. This finite window keeps unbounded recurrence rules responsive while one-time and recurring results share the same chronological result list. Agenda recurrence expansion is bounded to its visible 42-day window. Hidden calendars are excluded consistently from the month grid, agenda, appointment focus, and search. Visibility and agenda position are session-only presentation state; saves always retain events from hidden calendars.

Calendars containing features this version cannot preserve—such as RDATE periods or values incompatible with the event's DTSTART time basis or TZID, detached RECURRENCE-ID overrides, ordinal BYDAY, embedded VTIMEZONE definitions, alarms, or attendees—remain browsable but read-only. This guard prevents a local edit from silently discarding unknown data.

Every replacement checks that the source has not changed since it was loaded. If another Nocal instance or an external editor changes the file, the mutation is rejected and the in-memory change is rolled back. Successful replacements retain the immediately previous bytes as calendar.ics.bak. Run nocal --restore-backup [CALENDAR.ics] for an explicit, atomic recovery; the backup itself is kept so recovery can be repeated.

For a popup calendar binding and version-specific Hyprland rules, see Hyprland integration.

Remote-provider foundation

The remote-provider foundation is a private local SQLite database in WAL mode, separate from the interactive .ics file workflow. Its schema is migrated by version in all-or-nothing transactions. It preserves raw provider payloads, inactive calendars, and tombstoned events so synchronization cannot silently discard data that the current application does not understand. Each opaque provider cursor is scoped to a calendar and synchronization window and is committed atomically with the pulled page it represents. Normalized event instances provide immutable snapshot input, while transactional outbox and conflict records are reserved for later write synchronization.

The provider boundary models Microsoft sign-in as a public desktop client using the system browser, the OAuth authorization-code flow, PKCE with S256, and a loopback redirect. Nocal's application registration must be multitenant, with the intended organizational/personal Microsoft account audience. The read-only adapter requests delegated User.Read for /me plus Calendars.Read for calendar and event reads. Neither delegated permission normally requires administrator consent, but an organization's tenant policy can still require approval or block user consent. An open-source desktop executable cannot keep a client secret, so none is embedded or expected.

The generic HTTP boundary uses injected transports and deterministic fakes. It does not retry requests: provider code must later make retry decisions with the operation's idempotency and server response in view. The concrete Linux adapter uses libcurl with TLS verification and bounded responses and timeouts, and neither automatic redirects nor retries. Cleartext HTTP is limited to loopback tests. Browser launch executes xdg-open directly, never through a shell.

The loopback receiver binds only 127.0.0.1, advertises a dynamic-port localhost URL at the fixed /nocal/oauth/callback path, and accepts one bounded callback. Register Nocal in Entra as a Mobile and desktop public client with http://localhost/nocal/oauth/callback. Microsoft documents that the port is ignored when matching a localhost redirect, allowing a fresh local port; an HTTP 127.0.0.1 redirect requires manifest editing, while IPv6 loopback redirects are currently unsupported.

The token and Secret Service boundary parses token responses with strict size bounds. Initial authorization must return a refresh token; when Microsoft rotates it, the replacement is retained, while a refresh response that omits one keeps the previously stored refresh token. Tokens live only in a versioned libsecret payload keyed by an opaque local account ID. They never enter SQLite, logs, command-line arguments, or environment variables.

libsecret operations are synchronous but cancellable and must run on a worker thread, never the render thread. Isolated D-Bus and gnome-keyring tests exercise the real adapter. A missing secret means the account is disconnected; locked, cancelled, and storage failures remain distinct errors and never fall back to another storage channel. Nocal does not mark an account connected until the complete secret has been stored successfully.

The Microsoft Graph read phase calls /me and discovers every calendar through the complete paged listing, replacing the cached calendar set atomically only after every page succeeds. Provider/account identity produces stable deterministic local IDs. Event requests prefer immutable Graph IDs, parse strictly within fixed bounds, and keep credentials out of errors. Access tokens are supplied per operation; Secret Service remains the only persistent token store.

All Graph requests and opaque paging/delta links must use HTTPS on graph.microsoft.com. Event windows are half-open UTC ranges. Primary-calendar delta pages commit their changes and cursor atomically, so an interrupted round resumes without advancing past durable data.

Secondary-calendar reconciliation through stable Graph v1.0 reads the documented per-calendar calendarView, finishes every page, and then atomically replaces durable membership for that complete window. Events omitted from one window lose cached instances only after no other complete window references them; omission is not mislabeled as a remote deletion tombstone. This avoids beta and undocumented delta routes, at the cost of refetching each secondary window instead of incrementally updating it.

There is still no account UI/CLI orchestration or live Microsoft credential integration, and remote writes remain out of scope. Account setup and orchestration are next.

Description
No description provided
Readme 815 KiB
Languages
C++ 97.4%
Shell 2.1%
Meson 0.5%