feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,366 @@
|
||||
//! The CalDAV adapter: one `REPORT` per poll against a calendar
|
||||
//! collection, the occurrences read out of the iCalendar objects the
|
||||
//! server answers with.
|
||||
//!
|
||||
//! Wire choices and the constraints they pin:
|
||||
//! - The request is a `CALDAV:calendar-query` `REPORT` with a
|
||||
//! `time-range` on `VEVENT` and `Depth: 1`. The URL is the calendar
|
||||
//! collection's own, a copy-paste from the server UI; principal and
|
||||
//! home-set discovery are deliberately absent, because a hand-pasted
|
||||
//! URL is one config line and discovery is a protocol zoo.
|
||||
//! - The answer is a 207 multistatus whose `calendar-data` values are
|
||||
//! whole iCalendar objects. Servers return recurring masters plus
|
||||
//! overrides for the range, so the expansion in [`crate::ics`] does
|
||||
//! the client-side work and this module only fetches and parses.
|
||||
//! Occurrence ids carry the `caldav` prefix, so a calendar moved
|
||||
//! between adapters changes its dedup ids: feed URLs and DAV
|
||||
//! collections are different identities, not spellings of one.
|
||||
//! - Auth is Basic (username plus password, the self-hosted norm), a
|
||||
//! bearer token, or anonymous, whichever the server wants.
|
||||
//! Credentials ride the `Authorization` header only, never a URL or
|
||||
//! a log line.
|
||||
//! - A `time-range` `REPORT` filters server-side by overlap, so the
|
||||
//! adapter still clips client-side to the window's own contract:
|
||||
//! starts within `[from, to)`.
|
||||
//! - Non-event objects in the collection (a `VTODO` stored beside the
|
||||
//! events) expand to nothing. A `VEVENT` without `UID` or `DTSTART`
|
||||
//! fails the poll loudly, as in the ics adapter: a shape break must
|
||||
//! surface, not shrink the calendar.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::escape::unescape;
|
||||
use quick_xml::events::Event as XmlEvent;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::calendar::{Calendar, finish};
|
||||
use crate::error::{CalendarError, ensure_success};
|
||||
use crate::event::{CalendarEvent, Window};
|
||||
use crate::ics::expand_as;
|
||||
|
||||
/// The production spelling of nothing in particular: CalDAV has no
|
||||
/// vendor base to default to, the calendar's URL is configured whole.
|
||||
pub const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// How the adapter authenticates.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Auth {
|
||||
/// No credentials; servers that publish openly.
|
||||
Anonymous,
|
||||
/// Username plus password, the self-hosted norm.
|
||||
Basic { user: String, password: String },
|
||||
/// A bearer token, for servers that issue them.
|
||||
Bearer(String),
|
||||
}
|
||||
|
||||
/// CalDAV backend: a collection URL, an auth choice, one reqwest
|
||||
/// client.
|
||||
pub struct CaldavCalendar {
|
||||
url: String,
|
||||
auth: Auth,
|
||||
http: reqwest::Client,
|
||||
report: reqwest::Method,
|
||||
}
|
||||
|
||||
impl CaldavCalendar {
|
||||
/// A calendar collection at `url` (`https://` or `http://`; a
|
||||
/// self-hosted collection on the LAN is a legitimate http
|
||||
/// citizen). Anonymous until told otherwise.
|
||||
pub fn new(url: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead server fails fast instead of
|
||||
// stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every DAV call is a bounded request/response, so one
|
||||
// overall timeout per request is the right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
url: url.into().trim_end_matches('/').to_owned(),
|
||||
auth: Auth::Anonymous,
|
||||
http,
|
||||
// REPORT is an HTTP token, so this construction cannot
|
||||
// fail; reqwest has no const constructor for it.
|
||||
report: reqwest::Method::from_bytes(b"REPORT")
|
||||
.expect("REPORT is a valid HTTP method token"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticates with Basic credentials.
|
||||
pub fn with_basic(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
|
||||
self.auth = Auth::Basic {
|
||||
user: user.into(),
|
||||
password: password.into(),
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// Authenticates with a bearer token.
|
||||
pub fn with_token(mut self, token: impl Into<String>) -> Self {
|
||||
self.auth = Auth::Bearer(token.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Calendar for CaldavCalendar {
|
||||
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
let mut request = self
|
||||
.http
|
||||
.request(self.report.clone(), &self.url)
|
||||
.header("Depth", "1")
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
"application/xml; charset=utf-8",
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/xml")
|
||||
.body(report_body(window));
|
||||
match &self.auth {
|
||||
Auth::Anonymous => {}
|
||||
Auth::Basic { user, password } => {
|
||||
request = request.basic_auth(user, Some(password));
|
||||
}
|
||||
Auth::Bearer(token) => {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
}
|
||||
let response = request.send().await.map_err(CalendarError::network)?;
|
||||
let body = ensure_success(response)
|
||||
.await?
|
||||
.text()
|
||||
.await
|
||||
.map_err(CalendarError::network)?;
|
||||
let mut events = Vec::new();
|
||||
for object in parse_multistatus(&body)? {
|
||||
events.extend(expand_as(&object, "caldav", window)?);
|
||||
}
|
||||
Ok(finish(events, window))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `calendar-query` body: the collection's VEVENTs, bounded by the
|
||||
/// window's `time-range`. The bounds are RFC 4791's own spelling:
|
||||
/// compact, UTC, `Z`.
|
||||
fn report_body(window: &Window) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="{start}" end="{end}"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"#,
|
||||
start = compact_z(window.from),
|
||||
end = compact_z(window.to),
|
||||
)
|
||||
}
|
||||
|
||||
/// An instant as a CalDAV `time-range` bound: `YYYYMMDDTHHMMSSZ` in
|
||||
/// UTC, whatever offset the instant carried.
|
||||
fn compact_z(at: OffsetDateTime) -> String {
|
||||
let at = at.to_offset(time::UtcOffset::UTC);
|
||||
format!(
|
||||
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
|
||||
at.year(),
|
||||
u8::from(at.month()),
|
||||
at.day(),
|
||||
at.hour(),
|
||||
at.minute(),
|
||||
at.second()
|
||||
)
|
||||
}
|
||||
|
||||
/// An element's local name: CalDAV's prefixes (`D:`, `C:`) are
|
||||
/// conventions, not guarantees, and the answer's meaning lives in the
|
||||
/// namespace URIs the element binds. Matching local names inside a
|
||||
/// `prop` stat is the pragmatic read every DAV client ships.
|
||||
fn local_name(name: quick_xml::name::QName<'_>) -> &str {
|
||||
// The reader was built from a &str, so every borrowed name is
|
||||
// UTF-8 already; a decoding failure cannot say anything truer than
|
||||
// "not the element we were looking for".
|
||||
std::str::from_utf8(name.local_name().into_inner()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Pulls every `calendar-data` value out of a 207 multistatus, in
|
||||
/// document order. Whitespace-only values are dropped; anything else
|
||||
/// the server said (hrefs, etags, statuses) is beneath the vocabulary.
|
||||
fn parse_multistatus(xml: &str) -> Result<Vec<String>, CalendarError> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut datas = Vec::new();
|
||||
let mut capturing = false;
|
||||
let mut current = String::new();
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(XmlEvent::Start(element)) => {
|
||||
if local_name(element.name()) == "calendar-data" {
|
||||
capturing = true;
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
Ok(XmlEvent::Text(text)) if capturing => {
|
||||
// Escaped fragments ride Text; references ride their
|
||||
// own event (below). A server writes ` `
|
||||
// where the iCalendar needs CRLF, and an unexpanded
|
||||
// reference would fold the whole object into one line
|
||||
// and read as quietly empty.
|
||||
let decoded = text
|
||||
.xml10_content()
|
||||
.map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?;
|
||||
let decoded = unescape(&decoded)
|
||||
.map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?;
|
||||
current.push_str(&decoded);
|
||||
}
|
||||
Ok(XmlEvent::GeneralRef(reference)) if capturing => {
|
||||
match reference.resolve_char_ref() {
|
||||
// ` ` and friends: one character.
|
||||
Ok(Some(ch)) => current.push(ch),
|
||||
// A named entity (`&`): the escape table's.
|
||||
Ok(None) => {
|
||||
let raw = reference.xml10_content().map_err(|err| {
|
||||
CalendarError::Parse(format!("multistatus text: {err}"))
|
||||
})?;
|
||||
let entity = format!("&{raw};");
|
||||
let decoded = unescape(&entity).map_err(|err| {
|
||||
CalendarError::Parse(format!("multistatus text: {err}"))
|
||||
})?;
|
||||
current.push_str(&decoded);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(CalendarError::Parse(format!("multistatus text: {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(XmlEvent::CData(text)) if capturing => {
|
||||
current.push_str(&String::from_utf8_lossy(text.as_ref()));
|
||||
}
|
||||
Ok(XmlEvent::End(element)) => {
|
||||
if capturing && local_name(element.name()) == "calendar-data" {
|
||||
capturing = false;
|
||||
if !current.trim().is_empty() {
|
||||
datas.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(XmlEvent::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(CalendarError::Parse(format!("multistatus: {err}"))),
|
||||
}
|
||||
}
|
||||
Ok(datas)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
fn window(start: &str, hours: i64) -> Window {
|
||||
let from = OffsetDateTime::parse(start, &time::format_description::well_known::Rfc3339)
|
||||
.expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(hours))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_range_bounds_are_compact_utc() {
|
||||
// An offset instant normalizes: +02:00 noon is 10:00Z.
|
||||
let at = OffsetDateTime::parse("2026-10-20T12:00:00+02:00", &Rfc3339).expect("parses");
|
||||
assert_eq!(compact_z(at), "20261020T100000Z");
|
||||
assert_eq!(
|
||||
compact_z(window("2026-10-20T00:00:00Z", 24).to),
|
||||
"20261021T000000Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_names_the_window_in_caldavs_own_spelling() {
|
||||
let body = report_body(&window("2026-10-20T00:00:00Z", 24));
|
||||
assert!(
|
||||
body.contains(r#"<C:time-range start="20261020T000000Z" end="20261021T000000Z"/>"#)
|
||||
);
|
||||
assert!(body.contains(r#"<C:comp-filter name="VEVENT">"#));
|
||||
assert!(body.contains("calendar-query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multistatus_yields_every_calendar_data_in_order() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:href>/c/me/home/a.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"1"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR
|
||||
FIRST:a
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:href>/c/me/home/b.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<C:calendar-data>BEGIN:VCALENDAR
|
||||
FIRST:b
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
let datas = parse_multistatus(xml).expect("parses");
|
||||
assert_eq!(datas.len(), 2);
|
||||
assert!(datas[0].contains("FIRST:a"));
|
||||
assert!(datas[1].contains("FIRST:b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_missing_calendar_data_contribute_nothing() {
|
||||
let xml = r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"2"</D:getetag>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
assert!(parse_multistatus(xml).expect("parses").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_xml_is_a_loud_parse_error() {
|
||||
let err = parse_multistatus("<not closed").expect_err("loud");
|
||||
assert!(matches!(err, CalendarError::Parse(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vtodo_only_collection_expands_to_nothing() {
|
||||
// expand_as is the shared expansion; the CalDAV spelling of it
|
||||
// must treat a non-event object as quiet, not broken.
|
||||
let object = "BEGIN:VCALENDAR\nBEGIN:VTODO\nUID:t@x\nDTSTART:20261020T140000Z\n\
|
||||
END:VTODO\nEND:VCALENDAR\n";
|
||||
let events =
|
||||
expand_as(object, "caldav", &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The calendar client contract: one trait, one call.
|
||||
//!
|
||||
//! [`Calendar`] is the whole surface a consumer needs: the occurrences
|
||||
//! starting inside a window. Implementations are cheap to clone and
|
||||
//! share (the reqwest client inside is reference-counted) and
|
||||
//! object-safe so a backend can be swapped without generics.
|
||||
//!
|
||||
//! Polls are at-least-once and window-anchored. Nothing here tracks a
|
||||
//! cursor, because a calendar is addressable by time, not by history:
|
||||
//! re-asking the same window returns the same rows under the same
|
||||
//! ids, and the consumer's dispatcher dedups the repetition. There is
|
||||
//! deliberately no write half: reminders read a calendar; what a
|
||||
//! consumer does about one is an agent's business, not an adapter's.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::CalendarError;
|
||||
use crate::event::{CalendarEvent, Window};
|
||||
|
||||
/// A calendar backend: the client for one feed dialect, bound to one
|
||||
/// calendar (the URL for ICS, the calendar id for Google).
|
||||
#[async_trait]
|
||||
pub trait Calendar: Send + Sync {
|
||||
/// The occurrences whose start falls in `window`, oldest first,
|
||||
/// ties broken by id. Recurring series arrive expanded: one
|
||||
/// [`CalendarEvent`] per occurrence, overrides applied, cancelled
|
||||
/// occurrences gone.
|
||||
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError>;
|
||||
}
|
||||
|
||||
/// Folds collected events into an adapter's answer: keeps the rows
|
||||
/// starting in `window`, drops duplicate ids (first wins), and sorts
|
||||
/// by `(start, id)`. Adapters call this before returning, so a
|
||||
/// consumer reads one deterministic order regardless of which feed
|
||||
/// produced the rows.
|
||||
pub(crate) fn finish(mut events: Vec<CalendarEvent>, window: &Window) -> Vec<CalendarEvent> {
|
||||
events.retain(|event| window.starts_within(event.start));
|
||||
events.sort_by(|a, b| (&a.start, &a.id).cmp(&(&b.start, &b.id)));
|
||||
events.dedup_by(|a, b| a.id == b.id);
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn event(id: &str, start: &str) -> CalendarEvent {
|
||||
CalendarEvent {
|
||||
id: id.to_owned(),
|
||||
title: "x".to_owned(),
|
||||
start: time::OffsetDateTime::parse(
|
||||
start,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.expect("parses"),
|
||||
end: None,
|
||||
all_day: false,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: Vec::new(),
|
||||
organizer: None,
|
||||
url: None,
|
||||
raw: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_clips_sorts_and_dedups() {
|
||||
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
|
||||
let events = finish(
|
||||
vec![
|
||||
event("b", "2026-10-20T10:00:00Z"),
|
||||
event("z", "2026-10-19T10:00:00Z"), // before: dropped
|
||||
event("a", "2026-10-20T10:00:00Z"),
|
||||
event("b", "2026-10-20T10:00:00Z"), // duplicate id: dropped
|
||||
event("c", "2026-10-21T00:00:00Z"), // at `to`: dropped
|
||||
],
|
||||
&window,
|
||||
);
|
||||
let ids: Vec<&str> = events.iter().map(|event| event.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "b"]);
|
||||
}
|
||||
|
||||
fn at(raw: &str) -> time::OffsetDateTime {
|
||||
time::OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
|
||||
.expect("parses")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Why a calendar call failed.
|
||||
//!
|
||||
//! One error type for the whole crate: every adapter maps its transport,
|
||||
//! HTTP, and decoding failures onto it, so callers match one shape no
|
||||
//! matter which feed answered.
|
||||
|
||||
/// Everything that can go wrong between a [`crate::Calendar`] call and
|
||||
/// its answer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CalendarError {
|
||||
/// The feed answered with a non-2xx status. `body` is a bounded
|
||||
/// snippet of the response body, usually the server's error page or
|
||||
/// JSON.
|
||||
#[error("calendar api error (HTTP {status}): {body}")]
|
||||
Api {
|
||||
/// The HTTP status the feed answered with.
|
||||
status: u16,
|
||||
/// A bounded snippet of the response body.
|
||||
body: String,
|
||||
},
|
||||
/// A response body could not be decoded, or a wire row is missing a
|
||||
/// field the normalized vocabulary requires. Loud by design: feeds
|
||||
/// are machine-generated, and a shape break must surface, not
|
||||
/// silently shrink the calendar.
|
||||
#[error("malformed calendar response: {0}")]
|
||||
Parse(String),
|
||||
/// The server kept offering full pages, or offered a page cursor
|
||||
/// that leads nowhere. Polling stopped rather than looping forever.
|
||||
#[error("pagination failed: {0}")]
|
||||
Pagination(String),
|
||||
/// The request never got an answer (DNS, connect, timeout).
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// The longest body snippet an [`CalendarError::Api`] keeps: enough for
|
||||
/// any server's error JSON, small enough that error lines stay bounded.
|
||||
const BODY_SNIPPET_CAP: usize = 512;
|
||||
|
||||
impl CalendarError {
|
||||
/// Builds [`CalendarError::Api`] from a non-2xx status and the raw
|
||||
/// body, truncating the body to [`BODY_SNIPPET_CAP`] characters.
|
||||
pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self {
|
||||
let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect();
|
||||
CalendarError::Api {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a transport failure onto [`CalendarError::Network`];
|
||||
/// reqwest's error is neither `Clone` nor `Eq`, so only its display
|
||||
/// text travels.
|
||||
pub(crate) fn network(err: reqwest::Error) -> Self {
|
||||
CalendarError::Network(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A wire field the normalized vocabulary cannot do without. A missing
|
||||
/// one is a loud [`CalendarError::Parse`], not a silently skipped row.
|
||||
pub(crate) fn required<T>(field: Option<T>, what: &str) -> Result<T, CalendarError> {
|
||||
field.ok_or_else(|| CalendarError::Parse(format!("calendar row without {what}")))
|
||||
}
|
||||
|
||||
/// Passes a 2xx response through; turns anything else into
|
||||
/// [`CalendarError::Api`] with a bounded body snippet.
|
||||
pub(crate) async fn ensure_success(
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, CalendarError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(CalendarError::api(status, &body))
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The normalized event vocabulary shared by every calendar backend.
|
||||
//!
|
||||
//! Constraints the normalization pins (each adapter module documents how
|
||||
//! it meets them):
|
||||
//! - An event is one occurrence, never a series. Recurring masters are
|
||||
//! expanded (by the Google server with `singleEvents=true`, by the
|
||||
//! `ics` adapter client-side), so a consumer never sees a rule, only
|
||||
//! starts it can compare against its clock.
|
||||
//! - [`CalendarEvent::id`] is the dedup id a dispatcher keys on:
|
||||
//! `{provider}:{uid}@{unix start}`. Two polls of the same occurrence
|
||||
//! produce the same id; the id changes only when the occurrence
|
||||
//! itself is a different one. What a consumer does about the
|
||||
//! repetition (dispatch dedup, reminder slots) is the consumer's
|
||||
//! seam, not this crate's.
|
||||
//! - Times are instants, not wall-clock strings. A wire time with no
|
||||
//! offset reads as UTC, and an all-day date starts at its midnight
|
||||
//! UTC with [`CalendarEvent::all_day`] set, so every `start`
|
||||
//! compares against any clock without a timezone database on board.
|
||||
//! - [`CalendarEvent::end`] is exclusive, as every wire format spells
|
||||
//! it, and `None` when the row names no end.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use time::serde::rfc3339;
|
||||
|
||||
/// One occurrence on a calendar: a meeting, a birthday, a block of
|
||||
/// time. Everything a reminder needs to say, lifted from whichever
|
||||
/// dialect produced it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
/// Stable dedup id, `{provider}:{uid}@{unix start}` (e.g.
|
||||
/// `ics:c1f3a9@1792471200`,
|
||||
/// `google:_70ojsm0k6nojajk6@1792471200`): one occurrence, one id,
|
||||
/// across polls. A recurring series expands to one event per
|
||||
/// occurrence, each with its own.
|
||||
pub id: String,
|
||||
/// The title (`SUMMARY` / `summary`); empty when the row has none.
|
||||
pub title: String,
|
||||
/// When the occurrence starts.
|
||||
#[serde(with = "rfc3339")]
|
||||
pub start: OffsetDateTime,
|
||||
/// When it ends, exclusive; `None` when the row names no end.
|
||||
#[serde(with = "rfc3339::option")]
|
||||
pub end: Option<OffsetDateTime>,
|
||||
/// Whether this is an all-day occurrence: the wire carried a date
|
||||
/// with no time, and [`CalendarEvent::start`] is that date's
|
||||
/// midnight UTC.
|
||||
pub all_day: bool,
|
||||
/// Where it happens, when the row says.
|
||||
pub location: Option<String>,
|
||||
/// The row's notes, when it has any.
|
||||
pub description: Option<String>,
|
||||
/// Who is invited, bare addresses (`mailto:` stripped, Google's
|
||||
/// `email` fields).
|
||||
pub attendees: Vec<String>,
|
||||
/// The organizer's address, when the row names one.
|
||||
pub organizer: Option<String>,
|
||||
/// Human-facing URL of the event (`URL` / `htmlLink`).
|
||||
pub url: Option<String>,
|
||||
/// The untouched wire row: the adapter's parsed property map for
|
||||
/// ICS, the JSON object for Google. Parameters the vocabulary does
|
||||
/// not lift (`TZID`, for one) ride here for a consumer that can do
|
||||
/// more with them.
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
impl CalendarEvent {
|
||||
/// The event's duration: `end - start` when the row names an end,
|
||||
/// zero otherwise. All-day rows without an `DTEND` are a zero-width
|
||||
/// midnight: the wire says the day, the duration is not this
|
||||
/// crate's guess to widen.
|
||||
pub fn duration(&self) -> time::Duration {
|
||||
self.end
|
||||
.map(|end| end - self.start)
|
||||
.unwrap_or(time::Duration::ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stretch of time a poll asks about, as starts: an event belongs
|
||||
/// to a window when its [`CalendarEvent::start`] falls inside
|
||||
/// `[from, to)`. Windows are absolute (instants, not cursors), which
|
||||
/// is why a calendar poll needs no cursor: re-asking the same window
|
||||
/// returns the same rows under the same ids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
/// The earliest start included.
|
||||
pub from: OffsetDateTime,
|
||||
/// The latest start excluded.
|
||||
pub to: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// A window from `from` (inclusive) to `to` (exclusive).
|
||||
pub fn new(from: OffsetDateTime, to: OffsetDateTime) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
|
||||
/// Whether `start` falls inside the window.
|
||||
pub fn starts_within(&self, start: OffsetDateTime) -> bool {
|
||||
start >= self.from && start < self.to
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(raw: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
|
||||
.expect("rfc3339 parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_bounds_by_start_inclusively_below_exclusively() {
|
||||
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
|
||||
assert!(window.starts_within(at("2026-10-20T00:00:00Z")));
|
||||
assert!(window.starts_within(at("2026-10-20T23:59:59Z")));
|
||||
assert!(!window.starts_within(at("2026-10-19T23:59:59Z")));
|
||||
assert!(!window.starts_within(at("2026-10-21T00:00:00Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_round_trips_through_serde() {
|
||||
let event = CalendarEvent {
|
||||
id: "ics:abc@1".into(),
|
||||
title: "standup".into(),
|
||||
start: at("2026-10-20T09:00:00Z"),
|
||||
end: Some(at("2026-10-20T09:15:00Z")),
|
||||
all_day: false,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: vec!["a@example.net".into()],
|
||||
organizer: Some("b@example.net".into()),
|
||||
url: None,
|
||||
raw: serde_json::json!({ "SUMMARY": "standup" }),
|
||||
};
|
||||
let round: CalendarEvent =
|
||||
serde_json::from_value(serde_json::to_value(&event).expect("serializes"))
|
||||
.expect("deserializes");
|
||||
assert_eq!(round, event);
|
||||
assert_eq!(round.duration(), time::Duration::minutes(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_endless_event_has_no_duration() {
|
||||
let event = CalendarEvent {
|
||||
id: "ics:abc@1".into(),
|
||||
title: String::new(),
|
||||
start: at("2026-10-20T09:00:00Z"),
|
||||
end: None,
|
||||
all_day: true,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: Vec::new(),
|
||||
organizer: None,
|
||||
url: None,
|
||||
raw: serde_json::json!({}),
|
||||
};
|
||||
assert_eq!(event.duration(), time::Duration::ZERO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! The Google Calendar adapter: the Calendar API v3 events list.
|
||||
//!
|
||||
//! Auth is one credential, chosen by what the deployment has: an OAuth
|
||||
//! access token in `Authorization: Bearer` (the header only; the token
|
||||
//! never lands in a URL or a log line), or an API key for public
|
||||
//! calendars, which Google only accepts as the `key` query parameter.
|
||||
//!
|
||||
//! Endpoint choices and the constraints they pin:
|
||||
//! - `singleEvents=true` makes the server expand recurring masters into
|
||||
//! one row per occurrence, each with a stable `{id}_{timestamp}` id,
|
||||
//! so an occurrence's dedup id survives re-polls. The client side
|
||||
//! never evaluates a recurrence rule.
|
||||
//! - `timeMin`/`timeMax` bound the poll server-side. Google's filter is
|
||||
//! overlap-based, so the adapter still clips client-side to the
|
||||
//! window's own contract: starts within `[from, to)`.
|
||||
//! - Rows with `status: cancelled` are dropped. On a singleEvents poll
|
||||
//! a cancelled row is the tombstone of an occurrence that will not
|
||||
//! happen; keeping it would remind about a meeting that was called
|
||||
//! off.
|
||||
//! - Pagination rides `pageToken` until it disappears, with a hard
|
||||
//! page cap, so a server that keeps issuing cursors is declared
|
||||
//! misbehaving rather than polled forever.
|
||||
//! - `startTime` ordering plus `singleEvents` is the only ordering
|
||||
//! Google allows over expanded instances, which is the order the
|
||||
//! normalized answer promises anyway (oldest first, id-broken ties).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::calendar::{Calendar, finish};
|
||||
use crate::error::{CalendarError, ensure_success, required};
|
||||
use crate::event::{CalendarEvent, Window};
|
||||
|
||||
/// The production Google APIs base.
|
||||
pub const DEFAULT_BASE_URL: &str = "https://www.googleapis.com";
|
||||
|
||||
/// The user agent the adapter presents.
|
||||
const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Rows requested per page: Google's own maximum.
|
||||
const MAX_RESULTS: &str = "250";
|
||||
|
||||
/// Pages fetched before a server that keeps issuing cursors is
|
||||
/// declared misbehaving rather than polled forever.
|
||||
const MAX_PAGES: usize = 40;
|
||||
|
||||
/// Which credential a client authenticates with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Credential {
|
||||
/// An OAuth access token, in `Authorization: Bearer`.
|
||||
Bearer(String),
|
||||
/// An API key for public calendars, as the `key` parameter
|
||||
/// Google requires.
|
||||
ApiKey(String),
|
||||
}
|
||||
|
||||
/// Google Calendar backend: a credential, a calendar id, one reqwest
|
||||
/// client. The client is bound to one calendar, which is what the
|
||||
/// [`Calendar`] contract promises anyway.
|
||||
pub struct GoogleCalendar {
|
||||
base_url: String,
|
||||
calendar_id: String,
|
||||
credential: Credential,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GoogleCalendar {
|
||||
/// A calendar reading with an OAuth access `token` (private and
|
||||
/// public calendars both).
|
||||
pub fn new(token: impl Into<String>, calendar_id: impl Into<String>) -> Self {
|
||||
Self::with(Credential::Bearer(token.into()), calendar_id)
|
||||
}
|
||||
|
||||
/// A calendar reading with an API `key`: public calendars only,
|
||||
/// Google's rule, and the key rides the `key` query parameter
|
||||
/// because that is the only form Google accepts for it.
|
||||
pub fn with_api_key(key: impl Into<String>, calendar_id: impl Into<String>) -> Self {
|
||||
Self::with(Credential::ApiKey(key.into()), calendar_id)
|
||||
}
|
||||
|
||||
fn with(credential: Credential, calendar_id: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead endpoint fails fast instead
|
||||
// of stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every API call is a bounded request/response, so one
|
||||
// overall timeout per request is the right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||
calendar_id: calendar_id.into(),
|
||||
credential,
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the base URL (trailing slashes trimmed) — for a test
|
||||
/// against a mock server.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into().trim_end_matches('/').to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
/// The events-list URL, with the calendar id percent-encoded by
|
||||
/// the URL builder: ids are email-shaped (`primary`, an address,
|
||||
/// a hex id), but the encoding is the builder's job, not ours.
|
||||
fn endpoint(&self) -> Result<reqwest::Url, CalendarError> {
|
||||
let mut url = reqwest::Url::parse(&self.base_url)
|
||||
.map_err(|err| CalendarError::Parse(format!("base url: {err}")))?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| CalendarError::Parse("base url is not a directory".to_owned()))?
|
||||
.extend(["calendar", "v3", "calendars", &self.calendar_id, "events"]);
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Calendar for GoogleCalendar {
|
||||
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
let time_min = window
|
||||
.from
|
||||
.format(&Rfc3339)
|
||||
.map_err(|err| CalendarError::Parse(format!("window start: {err}")))?;
|
||||
let time_max = window
|
||||
.to
|
||||
.format(&Rfc3339)
|
||||
.map_err(|err| CalendarError::Parse(format!("window end: {err}")))?;
|
||||
let mut rows = Vec::new();
|
||||
let mut page_token = String::new();
|
||||
for _page in 1..=MAX_PAGES {
|
||||
let mut url = self.endpoint()?;
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("timeMin", &time_min);
|
||||
query.append_pair("timeMax", &time_max);
|
||||
query.append_pair("singleEvents", "true");
|
||||
query.append_pair("orderBy", "startTime");
|
||||
query.append_pair("maxResults", MAX_RESULTS);
|
||||
if let Credential::ApiKey(key) = &self.credential {
|
||||
query.append_pair("key", key);
|
||||
}
|
||||
if !page_token.is_empty() {
|
||||
query.append_pair("pageToken", &page_token);
|
||||
}
|
||||
}
|
||||
let mut request = self.http.get(url);
|
||||
if let Credential::Bearer(token) = &self.credential {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
let response = request.send().await.map_err(CalendarError::network)?;
|
||||
let body = ensure_success(response)
|
||||
.await?
|
||||
.text()
|
||||
.await
|
||||
.map_err(CalendarError::network)?;
|
||||
let page: EventsPage = serde_json::from_str(&body)
|
||||
.map_err(|err| CalendarError::Parse(format!("events page: {err}")))?;
|
||||
rows.extend(page.items);
|
||||
match page.next_page_token {
|
||||
Some(token) => page_token = token,
|
||||
None => return Ok(finish(try_rows(rows)?, window)),
|
||||
}
|
||||
}
|
||||
Err(CalendarError::Pagination(format!(
|
||||
"more than {MAX_PAGES} pages from the events list"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One events-list page: the raw rows travel as JSON so each can keep
|
||||
/// riding its event's `raw` untouched.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsPage {
|
||||
#[serde(default)]
|
||||
items: Vec<Value>,
|
||||
#[serde(rename = "nextPageToken", default)]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Decodes every row of a page, dropping the tombstones.
|
||||
fn try_rows(rows: Vec<Value>) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
rows.into_iter()
|
||||
.filter_map(|row| decode_row(row).transpose())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decodes one wire row; `None` for a cancelled tombstone. A missing
|
||||
/// start is loud: without it there is no instant to remind about.
|
||||
fn decode_row(row: Value) -> Result<Option<CalendarEvent>, CalendarError> {
|
||||
let row: EventRow = serde_json::from_value(row)
|
||||
.map_err(|err| CalendarError::Parse(format!("event row: {err}")))?;
|
||||
if row.status.as_deref() == Some("cancelled") {
|
||||
return Ok(None);
|
||||
}
|
||||
let start_block = required(row.start, "a start")?;
|
||||
let (start, all_day) = start_block.read(&row.id)?;
|
||||
let end = match row.end_time_unspecified {
|
||||
true => None,
|
||||
false => row
|
||||
.end
|
||||
.as_ref()
|
||||
.map(|end| end.read(&row.id))
|
||||
.transpose()?
|
||||
.map(|(when, _)| when),
|
||||
};
|
||||
Ok(Some(CalendarEvent {
|
||||
id: format!("google:{}@{}", row.id, start.unix_timestamp()),
|
||||
title: row.summary.unwrap_or_default(),
|
||||
start,
|
||||
end,
|
||||
all_day,
|
||||
location: row.location,
|
||||
description: row.description,
|
||||
attendees: row
|
||||
.attendees
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|attendee| attendee.email)
|
||||
.collect(),
|
||||
organizer: row.organizer.and_then(|organizer| organizer.email),
|
||||
url: row.html_link,
|
||||
raw: row.raw,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A wire time block: `dateTime` for a timed row, `date` for an
|
||||
/// all-day one (that date's midnight UTC).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TimeBlock {
|
||||
#[serde(default)]
|
||||
date: Option<String>,
|
||||
#[serde(rename = "dateTime", default)]
|
||||
date_time: Option<String>,
|
||||
}
|
||||
|
||||
impl TimeBlock {
|
||||
/// The instant plus the all-day flag; `None` when the block names
|
||||
/// neither form. An unreadable spelling is loud, with the row's id
|
||||
/// for context.
|
||||
fn read(&self, id: &str) -> Result<(OffsetDateTime, bool), CalendarError> {
|
||||
let loud = |what: &str, err: String| {
|
||||
CalendarError::Parse(format!("event {id:?} has an unreadable {what}: {err}"))
|
||||
};
|
||||
if let Some(when) = self.date.as_deref() {
|
||||
// `YYYY-MM-DD`, at midnight UTC.
|
||||
let (year, rest) = when
|
||||
.split_once('-')
|
||||
.ok_or_else(|| loud("date", "no year separator".to_owned()))?;
|
||||
let (month, day) = rest
|
||||
.split_once('-')
|
||||
.ok_or_else(|| loud("date", "no month separator".to_owned()))?;
|
||||
let date = time::Date::from_calendar_date(
|
||||
year.parse::<i32>()
|
||||
.map_err(|err| loud("date year", err.to_string()))?,
|
||||
time::Month::try_from(
|
||||
month
|
||||
.parse::<u8>()
|
||||
.map_err(|err| loud("date month", err.to_string()))?,
|
||||
)
|
||||
.map_err(|err| loud("date month", err.to_string()))?,
|
||||
day.parse::<u8>()
|
||||
.map_err(|err| loud("date day", err.to_string()))?,
|
||||
)
|
||||
.map_err(|err| loud("date", err.to_string()))?;
|
||||
return Ok((date.with_time(time::Time::MIDNIGHT).assume_utc(), true));
|
||||
}
|
||||
let when = self
|
||||
.date_time
|
||||
.as_deref()
|
||||
.ok_or_else(|| loud("start", "neither date nor dateTime".to_owned()))?;
|
||||
OffsetDateTime::parse(when, &Rfc3339)
|
||||
.map(|start| (start, false))
|
||||
.map_err(|err| loud("dateTime", err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire row, with the fields the vocabulary lifts named.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventRow {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
location: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "htmlLink", default)]
|
||||
html_link: Option<String>,
|
||||
#[serde(default)]
|
||||
start: Option<TimeBlock>,
|
||||
#[serde(default)]
|
||||
end: Option<TimeBlock>,
|
||||
#[serde(rename = "endTimeUnspecified", default)]
|
||||
end_time_unspecified: bool,
|
||||
#[serde(default)]
|
||||
attendees: Option<Vec<Party>>,
|
||||
#[serde(default)]
|
||||
organizer: Option<Party>,
|
||||
/// Kept whole for [`CalendarEvent::raw`].
|
||||
#[serde(flatten)]
|
||||
raw: Value,
|
||||
}
|
||||
|
||||
/// An attendee or organizer row; only the address is lifted.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Party {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn timed(id: &str, start: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"status": "confirmed",
|
||||
"summary": "standup",
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": "2026-10-20T09:15:00Z" },
|
||||
"htmlLink": "https://calendar.google.com/x",
|
||||
"attendees": [ { "email": "a@example.net" }, { "displayName": "no address" } ]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timed_row_decodes_with_its_lifts() {
|
||||
let event = decode_row(timed("abc123", "2026-10-20T09:00:00Z"))
|
||||
.expect("decodes")
|
||||
.expect("not cancelled");
|
||||
assert_eq!(
|
||||
event.id,
|
||||
format!(
|
||||
"google:abc123@{}",
|
||||
OffsetDateTime::parse("2026-10-20T09:00:00Z", &Rfc3339)
|
||||
.expect("parses")
|
||||
.unix_timestamp()
|
||||
)
|
||||
);
|
||||
assert_eq!(event.title, "standup");
|
||||
assert!(!event.all_day);
|
||||
assert_eq!(event.attendees, vec!["a@example.net".to_owned()]);
|
||||
assert_eq!(event.url.as_deref(), Some("https://calendar.google.com/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_day_row_starts_at_its_midnight() {
|
||||
let row = json!({
|
||||
"id": "bday",
|
||||
"summary": "birthday",
|
||||
"start": { "date": "2026-10-20" }
|
||||
});
|
||||
let event = decode_row(row).expect("decodes").expect("not cancelled");
|
||||
assert!(event.all_day);
|
||||
assert_eq!(event.end, None);
|
||||
assert_eq!(event.start.hour(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_row_is_a_tombstone() {
|
||||
let mut row = timed("abc123", "2026-10-20T09:00:00Z");
|
||||
row["status"] = json!("cancelled");
|
||||
assert_eq!(decode_row(row).expect("decodes"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_without_a_start_is_loud() {
|
||||
let mut row = timed("abc123", "2026-10-20T09:00:00Z");
|
||||
row["start"] = json!({});
|
||||
let err = decode_row(row).expect_err("loud");
|
||||
assert!(err.to_string().contains("start"), "got: {err}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
//! `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 loop, and no reminder
|
||||
//! policy 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 claw daemon today: one reminder slot per lead time,
|
||||
//! dispatched into the resident agent). Every [`crate::CalendarEvent`]
|
||||
//! carries an id that stays the same across polls of the same
|
||||
//! occurrence, so a consumer re-polling on its own cadence never
|
||||
//! re-fires a reminder it already fed; the repetition is the
|
||||
//! consumer's dispatcher's dedup, not this crate's problem.
|
||||
//!
|
||||
//! 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)
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub mod caldav;
|
||||
pub mod calendar;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod google;
|
||||
pub mod ics;
|
||||
|
||||
// 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;
|
||||
Reference in new issue
Block a user