It shipped with America/Denver hardcoded, taken from what dominates the historical data on the server. That data is old; the reader has moved. A zone belongs to whoever is looking at the calendar, and baking one in is the same mistake as v1's offset-instead-of-zone in miniature -- it looks right until the reader is somewhere else. Falls back to UTC rather than to a populated guess: an obviously neutral wrong answer gets noticed, a plausible one does not.
249 lines
7.7 KiB
Rust
249 lines
7.7 KiB
Rust
//! A terminal tool for pointing Runway's own stack at a real CalDAV server.
|
|
//!
|
|
//! Deliberately thin: it holds no logic of its own, so what it prints is what
|
|
//! `runway-caldav` fetched and what `runway-core` expanded. When a calendar
|
|
//! looks wrong in the browser, this is how to find out whether the problem is
|
|
//! in the view or underneath it — a question the previous iteration could only
|
|
//! answer by adding `println!`s to the backend and redeploying.
|
|
|
|
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
|
use chrono_tz::Tz;
|
|
use clap::{Parser, Subcommand};
|
|
use runway_caldav::{CalDavClient, Credentials};
|
|
use runway_core::model::Occurrence;
|
|
use runway_core::recurrence::{Window, Zones, expand, unresolved_zones};
|
|
use std::process::ExitCode;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "runway",
|
|
about = "Talk to a CalDAV server the way Runway does."
|
|
)]
|
|
struct Cli {
|
|
/// The DAV root, e.g. https://example.com/dav.php/
|
|
#[arg(long, env = "RUNWAY_CALDAV_URL")]
|
|
server: String,
|
|
|
|
#[arg(long, env = "RUNWAY_CALDAV_USER")]
|
|
user: String,
|
|
|
|
/// Read from the environment rather than the command line, so it does not
|
|
/// end up in shell history or in the process list.
|
|
#[arg(long, env = "RUNWAY_CALDAV_PASSWORD", hide_env_values = true)]
|
|
password: String,
|
|
|
|
/// The zone to show times in, and to read floating times as.
|
|
///
|
|
/// Defaults to whatever the machine is set to. Baking in a zone would be
|
|
/// the same mistake in miniature that this project exists to correct: the
|
|
/// right zone is a property of the reader, and readers move.
|
|
#[arg(long, default_value_t = local_timezone())]
|
|
timezone: Tz,
|
|
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
/// The system's own IANA zone, falling back to UTC when it cannot be read.
|
|
///
|
|
/// UTC rather than a guess at somewhere populated: a wrong zone that looks
|
|
/// plausible is worse than an obviously neutral one, because nobody checks it.
|
|
fn local_timezone() -> Tz {
|
|
iana_time_zone::get_timezone()
|
|
.ok()
|
|
.and_then(|name| name.parse().ok())
|
|
.unwrap_or(Tz::UTC)
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Command {
|
|
/// List the calendars the server offers.
|
|
Calendars,
|
|
|
|
/// List occurrences in a date range, expanded.
|
|
ListEvents {
|
|
/// Inclusive start date, YYYY-MM-DD.
|
|
#[arg(long)]
|
|
from: NaiveDate,
|
|
/// Exclusive end date, YYYY-MM-DD.
|
|
#[arg(long)]
|
|
to: NaiveDate,
|
|
/// Only this calendar, matched on href or display name. Repeatable.
|
|
#[arg(long = "calendar")]
|
|
calendars: Vec<String>,
|
|
},
|
|
|
|
/// Print the raw iCalendar for one resource, as the server stores it.
|
|
Show {
|
|
/// The href of the resource.
|
|
href: String,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> ExitCode {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "runway_cli=info,runway_caldav=info".into()),
|
|
)
|
|
.with_target(false)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
match run(cli).await {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(error) => {
|
|
eprintln!("error: {error}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|
let client = CalDavClient::new(&cli.server, Credentials::new(&cli.user, cli.password))?;
|
|
|
|
match cli.command {
|
|
Command::Calendars => list_calendars(&client).await,
|
|
Command::ListEvents {
|
|
from,
|
|
to,
|
|
calendars,
|
|
} => list_events(&client, from, to, &calendars, cli.timezone).await,
|
|
Command::Show { href } => show(&client, &href).await,
|
|
}
|
|
}
|
|
|
|
async fn list_calendars(client: &CalDavClient) -> Result<(), Box<dyn std::error::Error>> {
|
|
let calendars = client.discover().await?;
|
|
if calendars.is_empty() {
|
|
println!("no calendars on this server");
|
|
return Ok(());
|
|
}
|
|
for calendar in &calendars {
|
|
let colour = calendar.color.as_deref().unwrap_or("—");
|
|
println!("{:<28} {:<9} {}", calendar.name(), colour, calendar.href);
|
|
if !calendar.supports_events() {
|
|
println!("{:<28} (holds no events)", "");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_events(
|
|
client: &CalDavClient,
|
|
from: NaiveDate,
|
|
to: NaiveDate,
|
|
wanted: &[String],
|
|
timezone: Tz,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let zones = Zones::new(timezone);
|
|
let window = Window::new(midnight(timezone, from), midnight(timezone, to));
|
|
|
|
let calendars: Vec<_> = client
|
|
.discover()
|
|
.await?
|
|
.into_iter()
|
|
.filter(|c| c.supports_events())
|
|
.filter(|c| wanted.is_empty() || wanted.iter().any(|w| matches(c, w)))
|
|
.collect();
|
|
|
|
if calendars.is_empty() {
|
|
return Err("no calendar matched".into());
|
|
}
|
|
|
|
let mut rows: Vec<(String, Occurrence)> = Vec::new();
|
|
for calendar in &calendars {
|
|
let objects = client
|
|
.events_in_range(&calendar.href, window.from, window.to)
|
|
.await?;
|
|
|
|
for object in &objects {
|
|
// Reported, not swallowed: a zone we could not identify means the
|
|
// times below may be wrong by hours.
|
|
for zone in unresolved_zones(&object.calendar, zones) {
|
|
eprintln!("warning: {} names an unknown time zone {zone}", object.href);
|
|
}
|
|
for occurrence in expand(&object.calendar, window, zones)? {
|
|
rows.push((calendar.name().to_owned(), occurrence));
|
|
}
|
|
}
|
|
}
|
|
|
|
rows.sort_by_key(|(_, occurrence)| occurrence.start_utc);
|
|
|
|
println!(
|
|
"{} occurrence(s) between {from} and {to}, shown in {timezone}",
|
|
rows.len(),
|
|
);
|
|
for (calendar, occurrence) in &rows {
|
|
println!(
|
|
"{:<10} {:<13} {:<16} {}",
|
|
occurrence.start.date(),
|
|
span(occurrence, timezone),
|
|
truncate(calendar, 16),
|
|
label(occurrence),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn show(client: &CalDavClient, href: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
let calendar_path = href.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
|
|
let object = client
|
|
.get_object(&format!("{calendar_path}/"), href)
|
|
.await?;
|
|
if let Some(etag) = &object.etag {
|
|
eprintln!("etag: {etag}");
|
|
}
|
|
print!("{}", runway_core::ical::write(&object.calendar));
|
|
Ok(())
|
|
}
|
|
|
|
fn matches(calendar: &runway_caldav::Calendar, wanted: &str) -> bool {
|
|
calendar.href.contains(wanted) || calendar.name().eq_ignore_ascii_case(wanted)
|
|
}
|
|
|
|
fn midnight(tz: Tz, date: NaiveDate) -> DateTime<Utc> {
|
|
let local = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
|
runway_core::recurrence::instant_in(tz, local)
|
|
}
|
|
|
|
/// The time column: a clock range, or the fact that there is not one.
|
|
fn span(occurrence: &Occurrence, tz: Tz) -> String {
|
|
if occurrence.is_all_day() {
|
|
return "all-day".to_owned();
|
|
}
|
|
let clock = |instant: DateTime<Utc>| {
|
|
tz.from_utc_datetime(&instant.naive_utc())
|
|
.format("%H:%M")
|
|
.to_string()
|
|
};
|
|
format!(
|
|
"{}-{}",
|
|
clock(occurrence.start_utc),
|
|
clock(occurrence.end_utc)
|
|
)
|
|
}
|
|
|
|
fn label(occurrence: &Occurrence) -> String {
|
|
let title = occurrence.title().unwrap_or("(untitled)");
|
|
if occurrence.is_override {
|
|
format!("{title} [moved]")
|
|
} else if occurrence.recurrence_id.is_some() {
|
|
format!("{title} [recurring]")
|
|
} else {
|
|
title.to_owned()
|
|
}
|
|
}
|
|
|
|
fn truncate(text: &str, width: usize) -> String {
|
|
if text.chars().count() <= width {
|
|
return text.to_owned();
|
|
}
|
|
text.chars()
|
|
.take(width.saturating_sub(1))
|
|
.collect::<String>()
|
|
+ "…"
|
|
}
|