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:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

@@ -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}");
}
}