The reminder source skipped an already-fed slot only while the slot sat in the dispatcher's pending inbox; a claw restart drained that inbox, and every future lead of the same occurrence re-fed — a reminder fired two or three times per calendar event. The source keeps its own fed-slot ledger in the calendar store now (keyed by calendar, occurrence, and lead), records each slot right after a successful ingest, and prunes rows whose occurrence is an hour past every lead, so a pruned row can never resurrect a reminder. The store-backed source's ledger survives a restart; the static-list constructor keeps an in-memory one for the process lifetime, said so in the docs that previously claimed dedup was someone else's problem. Store failures surface loudly (a silently lost row is a re-fired reminder); the prune warns and continues. Offline tests cover the once-only rule, the restart via a tempdir reopen, and the prune.
89 lines
4.1 KiB
Rust
89 lines
4.1 KiB
Rust
//! `rhema-calendar-integration` — the calendar integration: one
|
|
//! normalized occurrence vocabulary and one client contract for the
|
|
//! calendar feeds a resident agent can remind about, with the adapters
|
|
//! that read them.
|
|
//!
|
|
//! An integration, not an agent: no driver, no model, and no loop
|
|
//! lives here. A consumer composes the crate with its own agent tier —
|
|
//! it picks a [`crate::Window`], polls a [`crate::Calendar`] for the
|
|
//! occurrences starting inside it, and decides what a start time
|
|
//! means. The default answer ships in [`crate::reminder`]: one
|
|
//! reminder slot per lead time, keyed `calendar:<name>`, fed to the
|
|
//! consumer's dispatcher as a `Source` — every reminder-shaped
|
|
//! consumer starts there. A consumer with different semantics brings
|
|
//! its own; every [`crate::CalendarEvent`] carries an id that stays
|
|
//! the same across polls of the same occurrence, so any
|
|
//! implementation re-polling on its own cadence never re-fires a
|
|
//! reminder it already fed. The default source keeps that promise in
|
|
//! its own fed-slot ledger; a custom source leans on the stable ids
|
|
//! and its dispatcher's dedup.
|
|
//!
|
|
//! Recurrence is expanded before a poll answers, so a consumer never
|
|
//! sees a rule, only starts it can compare against its clock:
|
|
//! - The Google Calendar adapter asks the server to expand
|
|
//! (`singleEvents=true`).
|
|
//! - The ICS and CalDAV adapters expand client-side, covering the
|
|
//! rule shapes servers actually emit (daily, weekly with `BYDAY`,
|
|
//! monthly, yearly; `INTERVAL`, `COUNT`, `UNTIL`, `EXDATE`) and
|
|
//! applying `RECURRENCE-ID` overrides. Anything outside those
|
|
//! shapes surfaces once, at its `DTSTART`, never silently dropped.
|
|
//! CalDAV shares the exact expansion: the server returns masters
|
|
//! plus overrides in range, and the adapter reads them out of the
|
|
//! multistatus. Where a server exposes both, the feed URL and the
|
|
//! DAV collection are different identities: their occurrence ids
|
|
//! carry different provider prefixes.
|
|
//!
|
|
//! Times are instants, not wall-clock strings. Every wire format (RFC
|
|
//! 3339, iCalendar's compact `YYYYMMDDTHHMMSS[Z]`, `VALUE=DATE`
|
|
//! all-days) normalizes to a `time::OffsetDateTime`; a floating
|
|
//! (offset-less) time reads as UTC, and a `TZID` parameter rides the
|
|
//! event's `raw` for a consumer that can resolve zones.
|
|
//!
|
|
//! ```
|
|
//! use rhema_calendar_integration::{Calendar, IcsCalendar, Window};
|
|
//! use time::format_description::well_known::Rfc3339;
|
|
//!
|
|
//! // A feed is one URL (webcal counts as https); the poll is one GET.
|
|
//! let feed = IcsCalendar::new("https://example.net/nowhere/calendar.ics");
|
|
//! let from = time::OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339)
|
|
//! .expect("parses");
|
|
//! let window = Window::new(from, from + time::Duration::hours(24));
|
|
//! // `feed.events(&window).await` answers with the occurrences whose
|
|
//! // start falls inside the window, oldest first; this doctest only
|
|
//! // assembles them.
|
|
//! let _ = (&feed, &window);
|
|
//! ```
|
|
//!
|
|
//! Layout:
|
|
//! - [`event`] — the normalized vocabulary: `CalendarEvent`, `Window`
|
|
//! - [`error`] — the crate's single calendar error type
|
|
//! - [`calendar`] — the `Calendar` client contract
|
|
//! - [`ics`] — the ICS/webcal adapter (one GET, expanded client-side)
|
|
//! - [`caldav`] — the CalDAV adapter (one REPORT, the same expansion)
|
|
//! - [`google`] — the Google Calendar API adapter (expanded server-side)
|
|
//! - [`reminder`] — the default reminder source: due slots per lead
|
|
//! time, `calendar:<name>` keys, one `Source` over the configured feeds
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
pub mod caldav;
|
|
pub mod calendar;
|
|
pub mod error;
|
|
pub mod event;
|
|
pub mod google;
|
|
pub mod ics;
|
|
pub mod reminder;
|
|
pub mod store;
|
|
pub use store::CalendarStore;
|
|
|
|
// Re-exported so implementors of the async seam can apply #[async_trait]
|
|
// without adding the crate themselves.
|
|
pub use async_trait::async_trait;
|
|
pub use caldav::CaldavCalendar;
|
|
pub use calendar::Calendar;
|
|
pub use error::CalendarError;
|
|
pub use event::{CalendarEvent, Window};
|
|
pub use google::GoogleCalendar;
|
|
pub use ics::IcsCalendar;
|
|
pub use reminder::{CalendarKind, CalendarSource, CalendarWatch, calendar_key};
|