Import events from an .ics file
Check / check (push) Successful in 1m25s
Check / guardrails (push) Successful in 1m10s
Check / bundle (push) Successful in 1m3s
Image / image (push) Successful in 2m17s

`runway-core::import::read` turns a file into an intent (add or cancel)
and one calendar per UID, carrying only the zones each references.
REPLY, COUNTER, DECLINECOUNTER, ADD and REFRESH are refused.

`runway-caldav` gains `objects_with_uid`, a calendar-query with a UID
prop-filter, re-checked for an exact match since text-match matches
substrings.

`POST /api/events/import` previews or imports. The preview reports each
event's start on the viewer's clock, any event with the same UID in any
calendar, and zones that cannot be resolved. The import checks every
item first, then creates, replaces with If-Match on the ETag the preview
showed, or cancels; a cancellation naming occurrences removes only those.
The file's UIDs are kept, so a later update to an invitation replaces it.
Body limit 1 MiB.

In the web client, "Import an .ics file…" sits under the calendar list
and a file dropped on the calendar does the same. The dialog lists each
event with what will happen to it, a calendar picker for new events, and
a checkbox to replace a newer version with an older file.

Tests: Baikal suites for the UID lookup and the endpoint (added to
run.sh), unit tests for reading files and for the dialog's plans, and an
import, update, dropped cancellation and unknown-zone pass in
e2e/shoot.mjs.

Refs #2
This commit is contained in:
connor committed 2026-10-20 14:32:23 -04:00
1 parent be3b995bb8
commit 4149abab4d
22 files changed
+2075 -11

No files matched your search

+30
View File
@@ -75,6 +75,36 @@ impl CalDavClient {
.collect())
}
/// The objects in a collection whose events carry this `UID`.
///
/// Matched exactly (`i;octet`), because a UID is an identifier and two
/// that differ only in case are different events. Normally zero or one
/// object; a server holding the same UID twice in one collection is
/// already broken, and both are returned so the caller can see it.
pub async fn objects_with_uid(
&self,
calendar_href: &str,
uid: &str,
) -> Result<Vec<CalendarObject>, CalDavError> {
let body = 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:prop-filter name="UID"><c:text-match collation="i;octet">{}</c:text-match></c:prop-filter>
</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>"#,
escape_xml(uid),
);
let responses = self.report(calendar_href, "1", body).await?;
// text-match is a substring match (RFC 4791 §9.7.5), so a UID that
// contains this one would also come back.
Ok(objects_from(calendar_href, responses)?
.into_iter()
.filter(|object| object.calendar.events.iter().any(|e| e.uid == uid))
.collect())
}
/// Fetches specific objects by href in one round trip.
pub async fn multiget(
&self,
+1
View File
@@ -59,3 +59,4 @@ cargo test -p runway-caldav --test live -- --test-threads=1 "$@"
cargo test -p runway-server --test auth -- --test-threads=1 "$@"
cargo test -p runway-server --test events -- --test-threads=1 "$@"
cargo test -p runway-server --test calendars -- --test-threads=1 "$@"
cargo test -p runway-server --test import -- --test-threads=1 "$@"
+42
View File
@@ -343,6 +343,48 @@ async fn an_event_can_be_moved_to_another_calendar() {
.await;
}
#[tokio::test]
async fn an_event_can_be_found_by_its_uid() {
with_calendar("by-uid", |client, calendar| async move {
for (uid, summary) in [
("runway-uid@test", "Wanted"),
("runway-uid@test-longer", "Contains the wanted UID"),
] {
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event(uid, summary, 9)),
&Precondition::New,
)
.await
.unwrap();
}
let found = client
.objects_with_uid(&calendar, "runway-uid@test")
.await
.unwrap();
assert_eq!(found.len(), 1, "a UID is matched whole, not as a substring");
assert_eq!(
found[0].calendar.events[0].summary.as_deref(),
Some("Wanted")
);
assert!(
found[0].etag.is_some(),
"with the ETag needed to replace it"
);
assert!(
client
.objects_with_uid(&calendar, "runway-absent@test")
.await
.unwrap()
.is_empty()
);
})
.await;
}
// -------------------------------------------------------------- time ranges --
#[tokio::test]
+102
View File
@@ -0,0 +1,102 @@
//! Reading an `.ics` file somebody was sent, as resources to write.
//!
//! A file from an email is not shaped like a CalDAV resource. It may hold
//! several events with different `UID`s, it carries a `METHOD` (RFC 5546) that
//! a stored resource must not (RFC 4791 §4.1), and it may define zones that
//! none of its events use. This turns one into the other: one [`VCalendar`]
//! per `UID`, each with only the zones its events reference, and the method
//! read off and kept separately.
//!
//! Only events are taken. A `VTODO` or `VJOURNAL` in the file is not
//! something an event import was asked for.
use crate::ical::{self, IcalError};
use crate::model::{VCalendar, VEvent};
/// What a file asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
/// Add or update the events: no `METHOD`, `PUBLISH`, or `REQUEST`.
Add,
/// Remove the events: `METHOD:CANCEL`.
Cancel,
}
/// A file read for import.
#[derive(Debug, Clone, PartialEq)]
pub struct Import {
pub intent: Intent,
/// One calendar per `UID`, in the order the file first mentions each.
/// A series and its overrides share a `UID` and so share a calendar.
pub resources: Vec<VCalendar>,
}
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error(transparent)]
Ical(#[from] IcalError),
#[error("the file has no events in it")]
NoEvents,
/// `REPLY`, `COUNTER` and `DECLINECOUNTER` answer an invitation rather
/// than describe an event.
#[error("the file is a {0} to an invitation, not an event")]
Response(String),
/// `ADD` and `REFRESH`, which only make sense to the organizer's server.
#[error("METHOD:{0} is not something a calendar can import")]
Unsupported(String),
}
/// Reads a file into the resources it describes.
pub fn read(input: &str) -> Result<Import, ImportError> {
let calendars = ical::parse_all(input)?;
if calendars.is_empty() {
return Err(IcalError::NoCalendar.into());
}
let method = calendars.iter().find_map(|c| c.method.clone());
let intent = match method.as_deref().map(str::to_ascii_uppercase).as_deref() {
None | Some("PUBLISH") | Some("REQUEST") => Intent::Add,
Some("CANCEL") => Intent::Cancel,
Some(reply @ ("REPLY" | "COUNTER" | "DECLINECOUNTER")) => {
return Err(ImportError::Response(reply.to_ascii_lowercase()));
}
Some(other) => return Err(ImportError::Unsupported(other.to_owned())),
};
let mut zones = Vec::new();
let mut groups: Vec<Vec<VEvent>> = Vec::new();
for calendar in calendars {
zones.extend(calendar.timezones);
for event in calendar.events {
match groups.iter_mut().find(|group| group[0].uid == event.uid) {
Some(group) => group.push(event),
None => groups.push(vec![event]),
}
}
}
if groups.is_empty() {
return Err(ImportError::NoEvents);
}
let resources = groups
.into_iter()
.map(|events| {
let mut calendar = VCalendar::with_events(events);
let used = calendar.referenced_tzids();
let mut wanted: Vec<_> = zones
.iter()
.filter(|zone| used.contains(&zone.tzid.as_str()))
.cloned()
.collect();
wanted.sort_by(|a, b| a.tzid.cmp(&b.tzid));
wanted.dedup_by(|a, b| a.tzid == b.tzid);
calendar.timezones = wanted;
calendar
})
.collect();
Ok(Import { intent, resources })
}
+4
View File
@@ -19,6 +19,10 @@ pub mod overlap;
#[cfg(feature = "ical")]
pub mod ical;
/// Reading an `.ics` file for import.
#[cfg(feature = "ical")]
pub mod import;
#[cfg(feature = "recurrence")]
pub mod recurrence;
+139
View File
@@ -0,0 +1,139 @@
//! Reading `.ics` files for import.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use runway_core::import::{ImportError, Intent, read};
use std::path::Path;
fn golden(path: &str) -> String {
let full = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(path);
std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("{}: {e}", full.display()))
}
/// A minimal file, with whatever `METHOD` line and events are given.
fn file(method: Option<&str>, events: &[(&str, &str)]) -> String {
let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n");
if let Some(method) = method {
out.push_str(&format!("METHOD:{method}\r\n"));
}
for (uid, summary) in events {
out.push_str(&format!(
"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261020T120000Z\r\n\
DTSTART:20261021T150000Z\r\nDTEND:20261021T160000Z\r\n\
SUMMARY:{summary}\r\nEND:VEVENT\r\n"
));
}
out.push_str("END:VCALENDAR\r\n");
out
}
#[test]
fn an_outlook_file_becomes_one_resource_per_uid() {
let import = read(&golden("outlook/feed-overrides-windows-tz.ics")).unwrap();
assert_eq!(import.intent, Intent::Add, "METHOD:PUBLISH adds");
let shape: Vec<(usize, Vec<&str>)> = import
.resources
.iter()
.map(|r| {
(
r.events.len(),
r.timezones.iter().map(|z| z.tzid.as_str()).collect(),
)
})
.collect();
assert_eq!(
shape,
vec![
(5, vec!["Pacific Standard Time"]),
(1, vec!["Mountain Standard Time"]),
(1, vec!["Central Standard Time"]),
],
"the series keeps its four overrides, and each resource carries only \
the zone its own events use",
);
assert!(
import.resources.iter().all(|r| r.method.is_none()),
"a stored resource must not carry METHOD (RFC 4791 4.1)",
);
}
#[test]
fn a_file_with_no_method_adds() {
let import = read(&file(None, &[("a@test", "Lunch")])).unwrap();
assert_eq!(import.intent, Intent::Add);
assert_eq!(import.resources.len(), 1);
assert_eq!(import.resources[0].events[0].uid, "a@test");
}
#[test]
fn an_invitation_adds() {
let import = read(&file(Some("REQUEST"), &[("a@test", "Lunch")])).unwrap();
assert_eq!(import.intent, Intent::Add);
}
#[test]
fn a_cancellation_is_read_as_one() {
let import = read(&file(Some("CANCEL"), &[("a@test", "Lunch")])).unwrap();
assert_eq!(import.intent, Intent::Cancel);
assert_eq!(import.resources[0].events[0].uid, "a@test");
}
#[test]
fn a_reply_is_refused() {
let refused = read(&file(Some("REPLY"), &[("a@test", "Lunch")]));
assert!(
matches!(refused, Err(ImportError::Response(ref kind)) if kind == "reply"),
"{refused:?}",
);
}
#[test]
fn a_method_only_an_organizer_can_use_is_refused() {
let refused = read(&file(Some("REFRESH"), &[("a@test", "Lunch")]));
assert!(
matches!(refused, Err(ImportError::Unsupported(_))),
"{refused:?}"
);
}
#[test]
fn several_events_keep_the_order_they_came_in() {
let import = read(&file(
None,
&[
("b@test", "Second"),
("a@test", "First"),
("c@test", "Third"),
],
))
.unwrap();
let uids: Vec<&str> = import
.resources
.iter()
.map(|r| r.events[0].uid.as_str())
.collect();
assert_eq!(uids, vec!["b@test", "a@test", "c@test"]);
}
#[test]
fn a_file_with_no_events_is_refused() {
let refused = read(&file(None, &[]));
assert!(matches!(refused, Err(ImportError::NoEvents)), "{refused:?}");
}
#[test]
fn a_file_that_is_not_icalendar_is_refused() {
let refused = read("this is an email, not a calendar");
assert!(matches!(refused, Err(ImportError::Ical(_))), "{refused:?}");
}
+7
View File
@@ -9,6 +9,7 @@ use crate::observability::{REQUEST_ID_HEADER, track};
use crate::routes;
use crate::state::AppState;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post, put};
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
@@ -24,6 +25,12 @@ pub fn router(state: AppState) -> Router {
.route("/api/events", post(routes::events::create))
.route("/api/events", put(routes::events::update))
.route("/api/events", delete(routes::events::delete))
// A meeting invitation is a few kilobytes; a megabyte is a file that is
// something else.
.route(
"/api/events/import",
post(routes::import::import).layer(DefaultBodyLimit::max(1 << 20)),
)
.route("/api/calendars", get(routes::calendars::list))
.route("/api/calendars", post(routes::calendars::create))
.route("/api/calendars", patch(routes::calendars::update))
+1 -1
View File
@@ -440,7 +440,7 @@ async fn fetch(
/// because the browser knows where it is right now and a stored preference can
/// be months out of date — somebody who moves should not have to go and change
/// a setting before their calendar makes sense.
async fn display_zone(
pub(crate) async fn display_zone(
state: &AppState,
user: &crate::db::User,
requested: Option<&str>,
+321
View File
@@ -0,0 +1,321 @@
//! Importing an `.ics` file somebody was sent.
//!
//! Two calls to one endpoint. A preview reads the file and says what importing
//! it would do, including which events already exist under the same `UID` in
//! any of the user's calendars. The import then does it, replacing or
//! cancelling only the events whose `ETag`s the client sends back from that
//! preview, so nothing is overwritten that the user was not shown.
//!
//! The file's `UID`s are kept. `POST /api/events` mints a fresh one because a
//! client-chosen `UID` could land on an unrelated event; here the lookup comes
//! first and the user confirms the match, and keeping the `UID` is what lets a
//! later update to the same invitation replace the event instead of adding a
//! second copy.
use crate::auth::CurrentUser;
use crate::error::ApiError;
use crate::routes::events::display_zone;
use crate::state::AppState;
use axum::Json;
use axum::extract::State;
use chrono::{NaiveDate, NaiveTime};
use chrono_tz::Tz;
use runway_caldav::{CalDavClient, Precondition, href_for};
use runway_core::import::{Intent, read};
use runway_core::model::{CalendarDateTime, CalendarObject, EditScope, VCalendar};
use runway_core::recurrence::{Zones, to_local, unresolved_zones};
use runway_core::series::{DeleteOutcome, apply_delete};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Deserialize)]
pub struct ImportRequest {
/// The file, as text.
pub ics: String,
/// Where new events go. Events that already exist stay where they are.
pub calendar_href: String,
/// Say what would happen, and write nothing.
#[serde(default)]
pub preview: bool,
/// `UID` to the `ETag` of the existing event the user agreed to replace or
/// cancel, as the preview reported it.
#[serde(default)]
pub confirmed: BTreeMap<String, String>,
}
#[derive(Debug, Serialize)]
pub struct ImportResponse {
pub intent: &'static str,
pub items: Vec<Item>,
}
/// One `UID` from the file: an event, or a series with its overrides.
#[derive(Debug, Serialize)]
pub struct Item {
pub uid: String,
pub summary: Option<String>,
pub start: CalendarDateTime,
/// The start on the viewer's clock, for showing. A time of `None` is an
/// all-day event.
pub local_date: NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_time: Option<NaiveTime>,
pub repeats: bool,
/// The file's `SEQUENCE`, to compare against the existing event's.
pub sequence: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub existing: Option<Existing>,
/// Zones the events name that cannot be resolved. Any here blocks the
/// import: the times would be guesses.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unresolved_zones: Vec<String>,
/// What the import did. Absent on a preview.
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<Outcome>,
/// Where the event is now, after an import that wrote it.
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
}
/// An event already stored under the same `UID`.
#[derive(Debug, Serialize)]
pub struct Existing {
pub calendar_href: String,
pub href: String,
pub etag: Option<String>,
pub sequence: u32,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Created,
Replaced,
Cancelled,
NothingToCancel,
}
/// `POST /api/events/import`
pub async fn import(
State(state): State<AppState>,
CurrentUser { user, .. }: CurrentUser,
Json(request): Json<ImportRequest>,
) -> Result<Json<ImportResponse>, ApiError> {
let file = read(&request.ics).map_err(|error| ApiError::BadRequest(error.to_string()))?;
let client = state.auth.caldav_for(&user).await?;
let tz = display_zone(&state, &user, None).await?;
let zones = Zones::new(tz);
let calendars: Vec<String> = client
.discover()
.await?
.into_iter()
.filter(|c| c.supports_events())
.map(|c| c.href)
.collect();
// Only an import needs somewhere to put new events. A preview is asked
// before the user has picked one.
if !request.preview && file.intent == Intent::Add && !calendars.contains(&request.calendar_href)
{
return Err(ApiError::NotFound(format!(
"no calendar at {}",
request.calendar_href
)));
}
let mut items = Vec::with_capacity(file.resources.len());
let mut stored = Vec::with_capacity(file.resources.len());
for resource in &file.resources {
let found = existing(&client, &calendars, &resource.events[0].uid).await?;
items.push(describe(resource, found.as_ref(), zones, tz));
stored.push(found);
}
if !request.preview {
check(&items, &request.confirmed)?;
for ((item, resource), found) in items.iter_mut().zip(&file.resources).zip(stored) {
let (outcome, href) = match file.intent {
Intent::Add => add(&client, &request.calendar_href, resource, found).await?,
Intent::Cancel => cancel(&client, resource, found, zones).await?,
};
item.outcome = Some(outcome);
item.href = href;
}
}
Ok(Json(ImportResponse {
intent: match file.intent {
Intent::Add => "add",
Intent::Cancel => "cancel",
},
items,
}))
}
/// The one stored object carrying this `UID`, across every calendar.
async fn existing(
client: &CalDavClient,
calendars: &[String],
uid: &str,
) -> Result<Option<CalendarObject>, ApiError> {
let mut found = Vec::new();
for calendar in calendars {
found.extend(client.objects_with_uid(calendar, uid).await?);
}
if found.len() > 1 {
let places: Vec<&str> = found.iter().map(|o| o.href.as_str()).collect();
return Err(ApiError::Conflict(format!(
"{uid} is stored more than once ({}); not choosing between them",
places.join(", ")
)));
}
Ok(found.pop())
}
fn describe(resource: &VCalendar, found: Option<&CalendarObject>, zones: Zones, tz: Tz) -> Item {
let head = resource.master().unwrap_or(&resource.events[0]);
let (local_date, local_time) = match &head.dtstart {
CalendarDateTime::Date { date } => (*date, None),
timed => {
let local = to_local(tz, zones.instant(timed));
(local.date(), Some(local.time()))
}
};
Item {
uid: head.uid.clone(),
summary: head.summary.clone(),
start: head.dtstart.clone(),
local_date,
local_time,
repeats: head.is_recurring(),
sequence: sequence_of(resource),
existing: found.map(|object| Existing {
calendar_href: object.calendar_path.clone(),
href: object.href.clone(),
etag: object.etag.clone(),
sequence: sequence_of(&object.calendar),
summary: object
.calendar
.master()
.or(object.calendar.events.first())
.and_then(|e| e.summary.clone()),
}),
unresolved_zones: unresolved_zones(resource, zones),
outcome: None,
href: None,
}
}
fn sequence_of(calendar: &VCalendar) -> u32 {
calendar
.events
.iter()
.map(|e| e.sequence)
.max()
.unwrap_or(0)
}
/// Everything that would stop an item, checked before anything is written so
/// an import either happens whole or not at all.
fn check(items: &[Item], confirmed: &BTreeMap<String, String>) -> Result<(), ApiError> {
for item in items {
let name = item.summary.as_deref().unwrap_or(&item.uid);
if !item.unresolved_zones.is_empty() {
return Err(ApiError::BadRequest(format!(
"{name} uses time zones that cannot be resolved: {}",
item.unresolved_zones.join(", ")
)));
}
// An existing event with no ETag cannot be replaced conditionally,
// so it cannot be confirmed either.
if let Some(existing) = &item.existing
&& (existing.etag.is_none() || confirmed.get(&item.uid) != existing.etag.as_ref())
{
return Err(ApiError::Conflict(format!(
"{name} already exists and changing it was not confirmed, or it \
changed since the preview"
)));
}
}
Ok(())
}
async fn add(
client: &CalDavClient,
calendar_href: &str,
resource: &VCalendar,
found: Option<CalendarObject>,
) -> Result<(Outcome, Option<String>), ApiError> {
match found {
None => {
let href = href_for(calendar_href, &resource.events[0].uid);
client
.put_object(&href, resource, &Precondition::New)
.await?;
Ok((Outcome::Created, Some(href)))
}
Some(object) => {
client
.put_object(&object.href, resource, &unchanged(&object)?)
.await?;
Ok((Outcome::Replaced, Some(object.href)))
}
}
}
/// Removes what a `METHOD:CANCEL` names: the whole event when the file cancels
/// the series, or only the occurrences it lists by `RECURRENCE-ID`.
async fn cancel(
client: &CalDavClient,
resource: &VCalendar,
found: Option<CalendarObject>,
zones: Zones,
) -> Result<(Outcome, Option<String>), ApiError> {
let Some(object) = found else {
return Ok((Outcome::NothingToCancel, None));
};
let mut remaining = object.calendar.clone();
let whole = resource.master().is_some();
if !whole {
for occurrence in resource.overrides() {
let removed = apply_delete(
&remaining,
EditScope::ThisOnly,
occurrence.recurrence_id.as_ref(),
zones,
)
.map_err(|error| ApiError::BadRequest(error.to_string()))?;
match removed {
DeleteOutcome::Replace(rest) => remaining = rest,
DeleteOutcome::Remove => {
remaining.events.clear();
break;
}
}
}
}
if whole || remaining.events.is_empty() {
client
.delete_object(&object.href, &unchanged(&object)?)
.await?;
Ok((Outcome::Cancelled, None))
} else {
client
.put_object(&object.href, &remaining, &unchanged(&object)?)
.await?;
Ok((Outcome::Cancelled, Some(object.href)))
}
}
/// The precondition for changing an object the preview showed. [`check`] has
/// already refused one with no `ETag`.
fn unchanged(object: &CalendarObject) -> Result<Precondition, ApiError> {
object
.etag
.clone()
.map(Precondition::Unchanged)
.ok_or_else(|| ApiError::Internal(format!("no ETag for {}", object.href)))
}
+1
View File
@@ -4,6 +4,7 @@ pub mod auth;
pub mod calendars;
pub mod events;
pub mod feeds;
pub mod import;
pub mod preferences;
use axum::Json;
+502
View File
@@ -0,0 +1,502 @@
//! Importing `.ics` files, end to end.
//!
//! Needs a server: `crates/runway-caldav/tests/baikal/run.sh`.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use runway_caldav::{CalDavClient, Credentials};
use runway_server::auth::SESSION_COOKIE;
use runway_server::db::Database;
use runway_server::{AppState, Config, router};
use serde_json::{Value, json};
use tower::ServiceExt;
const TZ: &str = "America/New_York";
/// A signed-in client with two scratch calendars.
struct Api {
state: AppState,
token: String,
home: String,
other: String,
}
impl Api {
async fn start(name: &str) -> Option<Self> {
let (url, user, password) = server()?;
let db = Database::in_memory().await.unwrap();
let state = AppState::with_database(db, Config::for_tests());
let (_, token) = state
.auth
.login_with_caldav(&url, &user, &password, None)
.await
.expect("could not log in to the test server");
let admin = CalDavClient::new(&url, Credentials::new(&user, password)).unwrap();
let home = format!("/dav.php/calendars/{user}/import-{name}/");
let other = format!("/dav.php/calendars/{user}/import-{name}-other/");
for calendar in [&home, &other] {
let _ = admin.delete_calendar(calendar).await;
admin
.create_calendar(calendar, calendar, None)
.await
.unwrap();
}
Some(Self {
state,
token,
home,
other,
})
}
async fn cleanup(&self) {
let (url, user, password) = server().unwrap();
let admin = CalDavClient::new(&url, Credentials::new(&user, password)).unwrap();
for calendar in [&self.home, &self.other] {
let _ = admin.delete_calendar(calendar).await;
}
}
async fn send(&self, method: Method, uri: &str, body: Option<Value>) -> Reply {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(header::COOKIE, format!("{SESSION_COOKIE}={}", self.token));
if body.is_some() {
builder = builder.header(header::CONTENT_TYPE, "application/json");
}
let request = builder
.body(body.map_or_else(Body::empty, |value| Body::from(value.to_string())))
.unwrap();
let response = router(self.state.clone()).oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 22)
.await
.unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
Reply {
status,
json: serde_json::from_str(&text).ok(),
text,
}
}
async fn import(&self, ics: &str, preview: bool, confirmed: Value) -> Reply {
self.send(
Method::POST,
"/api/events/import",
Some(json!({
"ics": ics,
"calendar_href": self.home,
"preview": preview,
"confirmed": confirmed,
})),
)
.await
}
/// Imports, asserting it worked, and returns the items.
async fn imported(&self, ics: &str) -> Vec<Value> {
let reply = self.import(ics, false, json!({})).await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
reply.json.unwrap()["items"].as_array().unwrap().clone()
}
/// Summaries of the occurrences in one calendar in October and November.
async fn summaries_in(&self, calendar: &str) -> Vec<String> {
let reply = self
.send(
Method::GET,
&format!("/api/events?from=2026-10-01&to=2026-12-01&tz={TZ}&calendars={calendar}"),
None,
)
.await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
reply.json.unwrap()["occurrences"]
.as_array()
.unwrap()
.iter()
.map(|o| {
o["event"]["summary"]
.as_str()
.unwrap_or_default()
.to_owned()
})
.collect()
}
}
struct Reply {
status: StatusCode,
json: Option<Value>,
text: String,
}
fn server() -> Option<(String, String, String)> {
let details = (|| {
Some((
std::env::var("RUNWAY_CALDAV_URL").ok()?,
std::env::var("RUNWAY_CALDAV_USER").ok()?,
std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?,
))
})();
assert!(
details.is_some() || !std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1"),
"RUNWAY_REQUIRE_CALDAV is set but no server is configured",
);
details
}
/// One event in a file, as the given lines.
fn vevent(uid: &str, lines: &[&str]) -> String {
let mut out = format!("BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261020T120000Z\r\n");
for line in lines {
out.push_str(line);
out.push_str("\r\n");
}
out.push_str("END:VEVENT\r\n");
out
}
fn ics(method: Option<&str>, events: &[String]) -> String {
let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n");
if let Some(method) = method {
out.push_str(&format!("METHOD:{method}\r\n"));
}
for event in events {
out.push_str(event);
}
out.push_str("END:VCALENDAR\r\n");
out
}
fn lunch(summary: &str, sequence: u32) -> String {
ics(
Some("REQUEST"),
&[vevent(
"lunch@example.com",
&[
"DTSTART:20261021T160000Z",
"DTEND:20261021T170000Z",
&format!("SUMMARY:{summary}"),
&format!("SEQUENCE:{sequence}"),
"ORGANIZER;CN=Ada:mailto:ada@example.com",
"ATTENDEE;CN=Connor;PARTSTAT=NEEDS-ACTION:mailto:connor@example.com",
],
)],
)
}
fn weekly() -> String {
ics(
Some("REQUEST"),
&[vevent(
"weekly@example.com",
&[
"DTSTART:20261005T140000Z",
"DTEND:20261005T150000Z",
"RRULE:FREQ=WEEKLY;COUNT=4",
"SUMMARY:Weekly",
],
)],
)
}
macro_rules! api_test {
($name:ident, |$api:ident| $body:block) => {
#[tokio::test]
async fn $name() {
let Some($api) = Api::start(stringify!($name)).await else {
eprintln!("SKIPPED: no CalDAV server configured");
return;
};
$body
$api.cleanup().await;
}
};
}
api_test!(a_preview_writes_nothing, |api| {
let reply = api.import(&lunch("Lunch", 0), true, json!({})).await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
let body = reply.json.unwrap();
assert_eq!(body["intent"], json!("add"));
assert_eq!(body["items"][0]["summary"], json!("Lunch"));
assert_eq!(body["items"][0]["local_date"], json!("2026-10-21"));
assert_eq!(
body["items"][0]["local_time"],
json!("16:00:00"),
"on the viewer's clock, which is UTC for a user with no zone set",
);
assert!(body["items"][0].get("existing").is_none());
assert!(body["items"][0].get("outcome").is_none());
assert_eq!(api.summaries_in(&api.home).await, Vec::<String>::new());
});
api_test!(an_import_keeps_the_uid_and_the_people, |api| {
let items = api.imported(&lunch("Lunch", 0)).await;
assert_eq!(items[0]["outcome"], json!("created"));
let href = items[0]["href"].as_str().unwrap();
assert!(
href.ends_with("/lunch%40example.com.ics"),
"the file's UID is what a later update to it will match on: {href}",
);
let reply = api
.send(
Method::GET,
&format!(
"/api/events?from=2026-10-01&to=2026-12-01&tz={TZ}&calendars={}",
api.home
),
None,
)
.await;
let event = &reply.json.unwrap()["occurrences"][0]["event"];
assert_eq!(event["uid"], json!("lunch@example.com"));
assert!(event["organizer"].is_object(), "{event}");
assert_eq!(event["attendees"].as_array().map(Vec::len), Some(1));
});
api_test!(importing_again_needs_the_existing_event_confirmed, |api| {
api.imported(&lunch("Lunch", 0)).await;
let unconfirmed = api
.import(&lunch("Lunch, moved", 1), false, json!({}))
.await;
assert_eq!(
unconfirmed.status,
StatusCode::CONFLICT,
"{}",
unconfirmed.text
);
let preview = api.import(&lunch("Lunch, moved", 1), true, json!({})).await;
let existing = preview.json.unwrap()["items"][0]["existing"].clone();
assert_eq!(existing["calendar_href"], json!(api.home));
assert_eq!(existing["summary"], json!("Lunch"));
let reply = api
.import(
&lunch("Lunch, moved", 1),
false,
json!({ "lunch@example.com": existing["etag"] }),
)
.await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
assert_eq!(
reply.json.unwrap()["items"][0]["outcome"],
json!("replaced")
);
assert_eq!(api.summaries_in(&api.home).await, vec!["Lunch, moved"]);
});
api_test!(an_update_replaces_the_event_where_it_already_is, |api| {
api.imported(&lunch("Lunch", 0)).await;
let preview = api.import(&lunch("Lunch, moved", 1), true, json!({})).await;
let etag = preview.json.unwrap()["items"][0]["existing"]["etag"].clone();
let reply = api
.send(
Method::POST,
"/api/events/import",
Some(json!({
"ics": lunch("Lunch, moved", 1),
"calendar_href": api.other,
"confirmed": { "lunch@example.com": etag },
})),
)
.await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
assert_eq!(api.summaries_in(&api.home).await, vec!["Lunch, moved"]);
assert_eq!(
api.summaries_in(&api.other).await,
Vec::<String>::new(),
"the picked calendar is for new events; an existing one is not copied",
);
});
api_test!(a_stale_confirmation_is_refused, |api| {
api.imported(&lunch("Lunch", 0)).await;
let reply = api
.import(
&lunch("Lunch, moved", 1),
false,
json!({ "lunch@example.com": "\"stale\"" }),
)
.await;
assert_eq!(reply.status, StatusCode::CONFLICT, "{}", reply.text);
assert_eq!(api.summaries_in(&api.home).await, vec!["Lunch"]);
});
api_test!(an_older_file_is_reported_as_older, |api| {
api.imported(&lunch("Lunch, moved", 3)).await;
let preview = api.import(&lunch("Lunch", 1), true, json!({})).await;
let item = preview.json.unwrap()["items"][0].clone();
assert_eq!(item["sequence"], json!(1));
assert_eq!(item["existing"]["sequence"], json!(3));
});
api_test!(a_cancellation_removes_the_event, |api| {
api.imported(&lunch("Lunch", 0)).await;
let cancel = ics(
Some("CANCEL"),
&[vevent(
"lunch@example.com",
&["DTSTART:20261021T160000Z", "SEQUENCE:1", "STATUS:CANCELLED"],
)],
);
let preview = api.import(&cancel, true, json!({})).await;
let body = preview.json.unwrap();
assert_eq!(body["intent"], json!("cancel"));
let etag = body["items"][0]["existing"]["etag"].clone();
let reply = api
.import(&cancel, false, json!({ "lunch@example.com": etag }))
.await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
assert_eq!(
reply.json.unwrap()["items"][0]["outcome"],
json!("cancelled")
);
assert_eq!(api.summaries_in(&api.home).await, Vec::<String>::new());
});
api_test!(cancelling_one_occurrence_leaves_the_rest, |api| {
api.imported(&weekly()).await;
let cancel = ics(
Some("CANCEL"),
&[vevent(
"weekly@example.com",
&[
"RECURRENCE-ID:20261012T140000Z",
"DTSTART:20261012T140000Z",
"SEQUENCE:1",
],
)],
);
let preview = api.import(&cancel, true, json!({})).await;
let etag = preview.json.unwrap()["items"][0]["existing"]["etag"].clone();
let reply = api
.import(&cancel, false, json!({ "weekly@example.com": etag }))
.await;
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
assert_eq!(api.summaries_in(&api.home).await.len(), 3, "four less one");
});
api_test!(cancelling_something_never_imported_does_nothing, |api| {
let cancel = ics(
Some("CANCEL"),
&[vevent("unknown@example.com", &["DTSTART:20261021T160000Z"])],
);
let items = api.imported(&cancel).await;
assert_eq!(items[0]["outcome"], json!("nothing_to_cancel"));
});
api_test!(an_unknown_zone_blocks_the_import, |api| {
let file = ics(
None,
&[vevent(
"zoned@example.com",
&[
"DTSTART;TZID=Somewhere/Imaginary:20261021T090000",
"DTEND;TZID=Somewhere/Imaginary:20261021T100000",
"SUMMARY:Zoned",
],
)],
);
let preview = api.import(&file, true, json!({})).await;
assert_eq!(
preview.json.unwrap()["items"][0]["unresolved_zones"],
json!(["Somewhere/Imaginary"]),
);
let reply = api.import(&file, false, json!({})).await;
assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text);
assert_eq!(api.summaries_in(&api.home).await, Vec::<String>::new());
});
api_test!(several_events_in_one_file_are_all_imported, |api| {
let file = ics(
None,
&[
vevent(
"one@example.com",
&["DTSTART:20261021T160000Z", "SUMMARY:One"],
),
vevent(
"two@example.com",
&["DTSTART:20261022T160000Z", "SUMMARY:Two"],
),
],
);
let items = api.imported(&file).await;
assert_eq!(items.len(), 2);
let mut found = api.summaries_in(&api.home).await;
found.sort();
assert_eq!(found, vec!["One", "Two"]);
});
api_test!(a_reply_is_refused, |api| {
let reply = api
.import(
&ics(
Some("REPLY"),
&[vevent("x@example.com", &["DTSTART:20261021T160000Z"])],
),
false,
json!({}),
)
.await;
assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text);
});
api_test!(an_outlook_file_with_windows_zones_imports, |api| {
let file = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../runway-core/tests/golden/outlook/feed-overrides-windows-tz.ics"
))
.unwrap();
let preview = api.import(&file, true, json!({})).await;
let items = preview.json.unwrap()["items"].as_array().unwrap().clone();
assert!(
items.iter().all(|i| i.get("unresolved_zones").is_none()),
"Windows zone names resolve: {items:?}",
);
let items = api.imported(&file).await;
assert_eq!(items.len(), 3);
assert!(items.iter().all(|i| i["outcome"] == json!("created")));
});
api_test!(a_file_that_does_not_parse_says_so, |api| {
let reply = api.import("not a calendar", false, json!({})).await;
assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text);
assert!(
reply.json.unwrap()["message"]
.as_str()
.is_some_and(|m| !m.is_empty()),
"a message to show, not just a status",
);
});
+1 -1
View File
@@ -19,7 +19,7 @@ gloo-net = { workspace = true }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
console_error_panic_hook = { workspace = true }
web-sys = { workspace = true, features = ["Document", "DomRect", "Element", "HtmlElement", "Location", "Navigator", "Notification", "NotificationOptions", "NotificationPermission", "Window"] }
web-sys = { workspace = true, features = ["Blob", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "File", "FileList", "HtmlElement", "HtmlInputElement", "Location", "Navigator", "Notification", "NotificationOptions", "NotificationPermission", "Window"] }
js-sys = { workspace = true }
[lints]
+73 -2
View File
@@ -1,10 +1,11 @@
//! Events: reading a range, and the three edit scopes.
//! Events: reading a range, the three edit scopes, and importing a file.
use super::transport::{ApiError, get, json, send};
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveTime};
use gloo_net::http::Request;
use runway_core::model::{CalendarDateTime, EditScope, Occurrence, VEvent};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One occurrence, with the CalDAV metadata needed to edit it.
///
@@ -110,6 +111,76 @@ pub async fn delete_event(request: &DeleteEvent) -> Result<DeleteResult, ApiErro
send(json(Request::delete("/api/events"), request)?).await
}
/// An `.ics` file to preview or import.
#[derive(Debug, Clone, Serialize)]
pub struct ImportFile {
pub ics: String,
/// Where new events go.
pub calendar_href: String,
pub preview: bool,
/// `UID` to the `ETag` the preview showed, for each existing event the
/// import may replace or cancel.
pub confirmed: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ImportResult {
pub intent: ImportIntent,
pub items: Vec<ImportItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportIntent {
Add,
Cancel,
}
/// One event in the file, or one series with its overrides.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ImportItem {
pub uid: String,
#[serde(default)]
pub summary: Option<String>,
/// The start on the viewer's clock. No time means all day.
pub local_date: NaiveDate,
#[serde(default)]
pub local_time: Option<NaiveTime>,
pub repeats: bool,
pub sequence: u32,
#[serde(default)]
pub existing: Option<ImportExisting>,
#[serde(default)]
pub unresolved_zones: Vec<String>,
#[serde(default)]
pub outcome: Option<ImportOutcome>,
}
/// The event already stored under the same `UID`.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ImportExisting {
pub calendar_href: String,
pub href: String,
#[serde(default)]
pub etag: Option<String>,
pub sequence: u32,
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportOutcome {
Created,
Replaced,
Cancelled,
NothingToCancel,
}
pub async fn import_events(request: &ImportFile) -> Result<ImportResult, ApiError> {
send(json(Request::post("/api/events/import"), request)?).await
}
/// Percent-encodes a query value.
///
/// Calendar hrefs are paths full of slashes; pasting one into a query string
+3 -2
View File
@@ -30,8 +30,9 @@ pub use calendars::{
delete_calendar, update_calendar,
};
pub use events::{
CreateEvent, DeleteEvent, DeleteResult, EventsResponse, PlacedOccurrence, UpdateEvent,
create_event, delete_event, events, update_event,
CreateEvent, DeleteEvent, DeleteResult, EventsResponse, ImportExisting, ImportFile,
ImportIntent, ImportItem, ImportOutcome, ImportResult, PlacedOccurrence, UpdateEvent,
create_event, delete_event, events, import_events, update_event,
};
pub use feeds::{
Feed, FeedEventsResponse, FeedFailure, FeedOccurrence, SubscribeFeed, UpdateFeed, feed_events,
@@ -0,0 +1,392 @@
//! Importing an `.ics` file: what is in it, what will happen, and one button.
//!
//! A summary rather than the event form. The form edits one event, and a file
//! can hold several, or a series with its overrides. Anything imported can be
//! edited in the form afterwards like any other event.
use crate::api::{self, ImportFile, ImportIntent, ImportResult};
use crate::import::{self, Plan};
use crate::state::{CalendarsStore, Importing, Overlay, PickedFile};
use leptos::prelude::*;
use std::collections::BTreeMap;
use wasm_bindgen_futures::JsFuture;
/// The largest file read. The server refuses anything bigger.
const LIMIT: f64 = 1024.0 * 1024.0;
/// Reads a picked or dropped file and opens the dialog on it.
pub fn open_file(importing: Importing, file: web_sys::File) {
let name = file.name();
if file.size() > LIMIT {
importing.open(PickedFile {
name,
text: Err("The file is over a megabyte, which is too big to be an invitation.".into()),
});
return;
}
leptos::task::spawn_local(async move {
let text = match JsFuture::from(file.text()).await {
Ok(value) => value
.as_string()
.ok_or_else(|| "The file is not text.".to_owned()),
Err(_) => Err("The file could not be read.".to_owned()),
};
importing.open(PickedFile { name, text });
});
}
#[component]
pub fn ImportDialog() -> impl IntoView {
let importing = expect_context::<Importing>();
move || importing.file().map(|file| view! { <Panel file /> })
}
#[component]
fn Panel(file: PickedFile) -> impl IntoView {
let importing = expect_context::<Importing>();
let overlay = expect_context::<Overlay>();
let calendars = expect_context::<CalendarsStore>();
let ics = StoredValue::new(file.text.clone());
let calendar = RwSignal::new(
calendars
.all()
.first()
.map(|c| c.href.clone())
.unwrap_or_default(),
);
let preview = RwSignal::new(None::<Result<ImportResult, api::ApiError>>);
let older_accepted = RwSignal::new(false);
let busy = RwSignal::new(false);
let finished = RwSignal::new(None::<String>);
let failure = RwSignal::new(None::<String>);
// Focused, so Escape works without a click into it first.
let dialog = NodeRef::<leptos::html::Div>::new();
Effect::new(move |_| {
if let Some(dialog) = dialog.get() {
let _ = dialog.focus();
}
});
if let Ok(text) = file.text.clone() {
leptos::task::spawn_local(async move {
let read = api::import_events(&ImportFile {
ics: text,
calendar_href: calendar.get_untracked(),
preview: true,
confirmed: BTreeMap::new(),
})
.await;
preview.set(Some(read));
});
}
// A calendar picked before the list had loaded would be empty.
Effect::new(move |_| {
if calendar.get().is_empty()
&& let Some(first) = calendars.all().first()
{
calendar.set(first.href.clone());
}
});
let name_of = move |href: &str| {
calendars
.all()
.into_iter()
.find(|c| c.href == href)
.map_or_else(|| href.to_owned(), |c| c.name)
};
let plans = Memo::new(move |_| match preview.get() {
Some(Ok(result)) => result
.items
.iter()
.map(|item| import::plan(result.intent, item, name_of))
.collect::<Vec<Plan>>(),
_ => Vec::new(),
});
let intent = Memo::new(move |_| match preview.get() {
Some(Ok(result)) => Some(result.intent),
_ => None,
});
let held_back = Memo::new(move |_| {
let plans = plans.get();
if plans.is_empty() {
return None;
}
import::blocked(&plans, older_accepted.get())
});
let submit = move |_| {
let Some(Ok(result)) = preview.get_untracked() else {
return;
};
let Ok(text) = ics.get_value() else {
return;
};
busy.set(true);
failure.set(None);
let request = ImportFile {
ics: text,
calendar_href: calendar.get_untracked(),
preview: false,
confirmed: import::confirmations(&result.items),
};
leptos::task::spawn_local(async move {
match api::import_events(&request).await {
Ok(done) => {
finished.set(Some(import::done(&done.items)));
overlay.changed();
}
Err(refused) => failure.set(Some(refused.message)),
}
busy.set(false);
});
};
view! {
<div
data-testid="import-backdrop"
on:click=move |_| importing.close()
style="position: fixed; inset: 0; z-index: 60; \
display: flex; align-items: center; justify-content: center; \
padding: var(--space-6); background: var(--scrim)"
>
<div
node_ref=dialog
tabindex="-1"
data-testid="import-dialog"
role="dialog"
aria-modal="true"
aria-label="Import events"
on:click=|click: leptos::ev::MouseEvent| click.stop_propagation()
on:keydown=move |key: leptos::ev::KeyboardEvent| {
if key.key() == "Escape" {
importing.close();
}
}
class="flex flex-col overflow-hidden border"
style="width: min(32rem, 100%); max-height: 100%; \
border-color: var(--border); border-radius: var(--radius-lg); \
border-width: var(--panel-border-width); \
background: var(--panel-surface); box-shadow: var(--shadow-lg)"
>
<header style="padding: var(--space-3) var(--space-4); \
border-bottom: var(--border-width) solid var(--border-subtle)">
<h2 style="margin: 0; font-size: var(--font-size); \
font-weight: var(--weight-strong); overflow-wrap: anywhere">
"Import " {file.name.clone()}
</h2>
</header>
<div class="min-h-0 flex-1 overflow-y-auto" style="padding: var(--space-4)">
{move || body(ics.get_value(), preview.get(), finished.get(), plans.get())}
{move || {
let new_events = plans.get().contains(&Plan::New);
(intent.get() == Some(ImportIntent::Add) && new_events
&& finished.get().is_none())
.then(|| {
view! {
<label
class="flex flex-col"
style="gap: var(--space-1); margin-top: var(--space-3); \
font-size: var(--font-size-sm)"
>
"Add new events to"
<select
data-testid="import-calendar"
prop:value=move || calendar.get()
on:change:target=move |picked| {
calendar.set(picked.target().value())
}
class="cursor-pointer border"
style="padding: var(--space-1) var(--space-2); \
border-radius: var(--radius); \
border-color: var(--border); \
background: var(--surface); color: var(--text); \
font-family: inherit; font-size: var(--font-size-sm)"
>
{calendars
.all()
.into_iter()
.map(|c| view! { <option value=c.href>{c.name}</option> })
.collect_view()}
</select>
</label>
}
})
}}
{move || {
(import::has_older(&plans.get()) && finished.get().is_none())
.then(|| {
view! {
<label
class="flex items-center"
style="gap: var(--space-2); margin-top: var(--space-3); \
font-size: var(--font-size-sm)"
>
<input
data-testid="import-older"
type="checkbox"
prop:checked=move || older_accepted.get()
on:change:target=move |ticked| {
older_accepted.set(ticked.target().checked())
}
/>
"Replace the newer version anyway"
</label>
}
})
}}
</div>
<footer
class="flex items-center justify-between"
style="gap: var(--space-3); padding: var(--space-3) var(--space-4); \
border-top: var(--border-width) solid var(--border-subtle)"
>
<p
role="alert"
data-testid="import-held"
class="min-w-0 flex-1"
style="margin: 0; color: var(--danger); font-size: var(--font-size-xs)"
>
{move || {
if finished.get().is_some() {
None
} else {
failure.get().or_else(|| held_back.get().map(str::to_owned))
}
}}
</p>
<div class="flex" style="gap: var(--space-2)">
<button
data-testid="import-cancel"
on:click=move |_| importing.close()
class="cursor-pointer border"
style="padding: var(--space-1) var(--space-3); \
border-radius: var(--radius); border-color: var(--border); \
background: var(--surface); color: var(--text); \
font-family: inherit; font-size: var(--font-size-sm)"
>
{move || if finished.get().is_some() { "Close" } else { "Cancel" }}
</button>
<Show when=move || finished.get().is_none() && intent.get().is_some()>
<button
data-testid="import-confirm"
on:click=submit
disabled=move || busy.get() || held_back.get().is_some()
class="cursor-pointer border"
style="padding: var(--space-1) var(--space-4); \
border-radius: var(--radius); border-color: var(--accent); \
background: var(--accent); color: var(--accent-text); \
font-family: inherit; font-size: var(--font-size-sm); \
font-weight: var(--weight-strong)"
>
{move || match (busy.get(), intent.get()) {
(true, _) => "Importing…",
(false, Some(ImportIntent::Cancel)) => "Remove",
(false, _) => "Import",
}}
</button>
</Show>
</div>
</footer>
</div>
</div>
}
}
/// The middle of the dialog: what could not be read, what is in the file, or
/// what was done with it.
fn body(
text: Result<String, String>,
preview: Option<Result<ImportResult, api::ApiError>>,
finished: Option<String>,
plans: Vec<Plan>,
) -> AnyView {
if let Err(unreadable) = text {
return view! { <Problem text=unreadable /> }.into_any();
}
if let Some(sentence) = finished {
return view! {
<p data-testid="import-done" style="margin: 0; font-size: var(--font-size-sm)">
{sentence}
</p>
}
.into_any();
}
match preview {
None => view! {
<p style="margin: 0; color: var(--text-subtle); font-size: var(--font-size-sm)">
"Reading…"
</p>
}
.into_any(),
Some(Err(refused)) => view! { <Problem text=refused.message /> }.into_any(),
Some(Ok(result)) => view! {
<ul
data-testid="import-items"
class="flex flex-col"
style="gap: var(--space-2); margin: 0; padding: 0; list-style: none"
>
{result
.items
.into_iter()
.zip(plans)
.map(|(item, plan)| {
let warn = matches!(
plan,
Plan::Blocked(_) | Plan::NothingToCancel | Plan::Replaces { older: true, .. }
);
view! {
<li
data-testid="import-item"
class="border"
style="padding: var(--space-2) var(--space-3); \
border-radius: var(--radius); \
border-color: var(--border-subtle); \
font-size: var(--font-size-sm)"
>
<div style="font-weight: var(--weight-strong)">
{item.summary.clone().unwrap_or_else(|| "(untitled)".to_owned())}
</div>
<div style="color: var(--text-muted); font-size: var(--font-size-xs)">
{import::when(&item)}
{item.repeats.then_some(" · repeats")}
</div>
<div
data-testid="import-plan"
style=format!(
"margin-top: var(--space-1); font-size: var(--font-size-xs); \
color: {}",
if warn { "var(--danger)" } else { "var(--text-muted)" },
)
>
{plan.describe()}
</div>
</li>
}
})
.collect_view()}
</ul>
}
.into_any(),
}
}
#[component]
fn Problem(text: String) -> impl IntoView {
view! {
<p
role="alert"
data-testid="import-error"
style="margin: 0; color: var(--danger); font-size: var(--font-size-sm)"
>
{text}
</p>
}
}
+2
View File
@@ -7,6 +7,7 @@ mod dragging;
mod event_details;
mod event_form;
mod header;
mod import_dialog;
mod login;
mod month_view;
mod print_sheet;
@@ -25,6 +26,7 @@ pub use dragging::{DragPreview, DragScope, Dragging};
pub use event_details::EventDetails;
pub use event_form::EventForm;
pub use header::Header;
pub use import_dialog::{ImportDialog, open_file};
pub use login::Login;
pub use month_view::MonthView;
pub use print_sheet::PrintSheet;
+35 -4
View File
@@ -7,12 +7,12 @@
use crate::api::{self, User};
use crate::clock;
use crate::components::{
AgendaView, ContextMenu, DragScope, Dragging, EventDetails, EventForm, Header, MonthView,
Reminders, Sidebar, TimeGrid, YearView,
AgendaView, ContextMenu, DragScope, Dragging, EventDetails, EventForm, Header, ImportDialog,
MonthView, Reminders, Sidebar, TimeGrid, YearView, open_file,
};
use crate::dates;
use crate::load::{self, Loaded};
use crate::state::{Auth, Overlay, PreferencesStore};
use crate::state::{Auth, Importing, Overlay, PreferencesStore};
use chrono::{Datelike, NaiveDate};
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map};
@@ -98,8 +98,33 @@ pub fn Shell() -> impl IntoView {
display_name: None,
});
// A file picked in the sidebar or dropped anywhere, for the dialog in the
// view area. Provided here because those are siblings.
let importing = Importing::new();
provide_context(importing);
view! {
<div class="flex h-screen flex-col overflow-hidden">
<div
class="flex h-screen flex-col overflow-hidden"
// A file dropped anywhere on the calendar is an import. Only drags
// carrying files are claimed; moving an event is pointer events,
// not HTML drag and drop, and is untouched.
on:dragover=move |drag: leptos::ev::DragEvent| {
if carries_files(&drag) {
drag.prevent_default();
}
}
on:drop=move |drag: leptos::ev::DragEvent| {
let dropped = drag
.data_transfer()
.and_then(|data| data.files())
.and_then(|files| files.get(0));
if let Some(file) = dropped {
drag.prevent_default();
open_file(importing, file);
}
}
>
<Header
view
focus
@@ -245,6 +270,7 @@ fn ViewArea(
<Reminders events=drawable now=now.into() />
<EventDetails />
<EventForm zone />
<ImportDialog />
<ContextMenu />
<DragScope />
@@ -315,3 +341,8 @@ fn ViewArea(
</section>
}
}
fn carries_files(drag: &leptos::ev::DragEvent) -> bool {
drag.data_transfer()
.is_some_and(|data| data.types().includes(&"Files".into(), 0))
}
+44 -1
View File
@@ -10,9 +10,10 @@
//! The sidebar already is the list; Settings is about the person reading it.
//! What each control does lives in [`super::sources`].
use super::open_file;
use super::sources::{Editing, Managing, Source, SourceEditor, SourceMenu};
use crate::api::{Calendar, ColorSource, Feed};
use crate::state::{CalendarsStore, FeedsStore};
use crate::state::{CalendarsStore, FeedsStore, Importing};
use leptos::prelude::*;
#[component]
@@ -60,6 +61,8 @@ pub fn Sidebar() -> impl IntoView {
}}
</Section>
<ImportButton />
<Section
title="Subscribed"
add="Subscribe to a calendar"
@@ -106,6 +109,46 @@ pub fn Sidebar() -> impl IntoView {
}
}
/// Opens a file picker for an `.ics` file. The input is hidden because a
/// file input cannot be styled; the button clicks it.
#[component]
fn ImportButton() -> impl IntoView {
let importing = expect_context::<Importing>();
let picker = NodeRef::<leptos::html::Input>::new();
view! {
<input
node_ref=picker
data-testid="import-file"
type="file"
accept=".ics,text/calendar"
style="display: none"
on:change:target=move |picked| {
let input = picked.target();
if let Some(file) = input.files().and_then(|files| files.get(0)) {
open_file(importing, file);
}
// Cleared, so picking the same file again still fires.
input.set_value("");
}
/>
<button
data-testid="import-ics"
on:click=move |_| {
if let Some(input) = picker.get() {
input.click();
}
}
class="cursor-pointer"
style="display: block; margin: calc(-1 * var(--space-3)) 0 var(--space-6); \
padding: 0; border: 0; background: none; color: var(--accent); \
font-family: inherit; font-size: var(--font-size-sm); text-align: left"
>
"Import an .ics file…"
</button>
}
}
#[component]
fn Section(
title: &'static str,
+242
View File
@@ -0,0 +1,242 @@
//! What importing a file will do to each event in it, said before it is done.
//!
//! The dialog shows one line per event and one button. Everything that decides
//! what those say is here, where it can be tested without a browser.
use crate::api::{ImportIntent, ImportItem, ImportOutcome};
use std::collections::BTreeMap;
/// What will happen to one event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Plan {
/// Added to the picked calendar.
New,
/// Replaces the event with the same `UID` in `calendar`. `older` when the
/// file's `SEQUENCE` is below the stored one: the file predates an update
/// already received.
Replaces { calendar: String, older: bool },
/// Removes the event, or the occurrences named, from `calendar`.
Cancels { calendar: String },
/// A cancellation of something that is not in any calendar.
NothingToCancel,
/// Names zones that cannot be resolved, so its times would be guesses.
Blocked(Vec<String>),
}
impl Plan {
pub fn describe(&self) -> String {
match self {
Self::New => "New".to_owned(),
Self::Replaces {
calendar,
older: false,
} => format!("Replaces the event in {calendar}"),
Self::Replaces {
calendar,
older: true,
} => format!("Replaces a newer version of this event in {calendar}"),
Self::Cancels { calendar } => format!("Cancels the event in {calendar}"),
Self::NothingToCancel => "Not in any calendar; nothing to cancel".to_owned(),
Self::Blocked(zones) => format!("Unknown time zone: {}", zones.join(", ")),
}
}
}
/// The plan for one item. `name_of` turns a calendar's href into its name.
pub fn plan(intent: ImportIntent, item: &ImportItem, name_of: impl Fn(&str) -> String) -> Plan {
if !item.unresolved_zones.is_empty() {
return Plan::Blocked(item.unresolved_zones.clone());
}
match (intent, &item.existing) {
(ImportIntent::Add, None) => Plan::New,
(ImportIntent::Add, Some(existing)) => Plan::Replaces {
calendar: name_of(&existing.calendar_href),
older: item.sequence < existing.sequence,
},
(ImportIntent::Cancel, None) => Plan::NothingToCancel,
(ImportIntent::Cancel, Some(existing)) => Plan::Cancels {
calendar: name_of(&existing.calendar_href),
},
}
}
/// Why Import cannot be pressed, or `None` when it can.
pub fn blocked(plans: &[Plan], older_accepted: bool) -> Option<&'static str> {
if plans.iter().any(|p| matches!(p, Plan::Blocked(_))) {
return Some("Some events use a time zone that cannot be read, so nothing is imported.");
}
if plans.iter().all(|p| *p == Plan::NothingToCancel) {
return Some("None of the cancelled events are in your calendars.");
}
if !older_accepted
&& plans
.iter()
.any(|p| matches!(p, Plan::Replaces { older: true, .. }))
{
return Some("This file is older than what is in your calendar. Confirm to replace it.");
}
None
}
/// Whether any event would replace a newer one, which the user has to agree to.
pub fn has_older(plans: &[Plan]) -> bool {
plans
.iter()
.any(|p| matches!(p, Plan::Replaces { older: true, .. }))
}
/// The `ETag` of every existing event the preview showed, which the import
/// may change. Sent back so the server changes only what was seen.
pub fn confirmations(items: &[ImportItem]) -> BTreeMap<String, String> {
items
.iter()
.filter_map(|item| {
let etag = item.existing.as_ref()?.etag.clone()?;
Some((item.uid.clone(), etag))
})
.collect()
}
/// When an item starts, on the viewer's clock.
pub fn when(item: &ImportItem) -> String {
let day = item.local_date.format("%a %-d %b %Y");
match item.local_time {
Some(time) => format!("{day}, {}", time.format("%H:%M")),
None => format!("{day}, all day"),
}
}
/// What an import did, in a sentence.
pub fn done(items: &[ImportItem]) -> String {
let count = |outcome| items.iter().filter(|i| i.outcome == Some(outcome)).count();
let parts: Vec<String> = [
(ImportOutcome::Created, "added"),
(ImportOutcome::Replaced, "replaced"),
(ImportOutcome::Cancelled, "cancelled"),
]
.into_iter()
.filter_map(|(outcome, verb)| {
let n = count(outcome);
(n > 0).then(|| format!("{n} {verb}"))
})
.collect();
if parts.is_empty() {
"Nothing changed.".to_owned()
} else {
let mut sentence = parts.join(", ");
sentence[..1].make_ascii_uppercase();
format!("{sentence}.")
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::api::ImportExisting;
use chrono::{NaiveDate, NaiveTime};
fn item(existing: Option<u32>, sequence: u32) -> ImportItem {
ImportItem {
uid: "lunch@example.com".to_owned(),
summary: Some("Lunch".to_owned()),
local_date: NaiveDate::from_ymd_opt(2026, 10, 21).expect("a real date"),
local_time: NaiveTime::from_hms_opt(16, 0, 0),
repeats: false,
sequence,
existing: existing.map(|stored| ImportExisting {
calendar_href: "/cal/work/".to_owned(),
href: "/cal/work/lunch.ics".to_owned(),
etag: Some("\"7\"".to_owned()),
sequence: stored,
summary: Some("Lunch".to_owned()),
}),
unresolved_zones: Vec::new(),
outcome: None,
}
}
fn name(href: &str) -> String {
if href == "/cal/work/" { "Work" } else { href }.to_owned()
}
#[test]
fn an_event_not_yet_stored_is_new() {
assert_eq!(plan(ImportIntent::Add, &item(None, 0), name), Plan::New);
}
#[test]
fn an_update_names_the_calendar_it_replaces_in() {
assert_eq!(
plan(ImportIntent::Add, &item(Some(0), 1), name),
Plan::Replaces {
calendar: "Work".to_owned(),
older: false
},
);
}
#[test]
fn an_older_file_needs_agreeing_to() {
let plans = [plan(ImportIntent::Add, &item(Some(3), 1), name)];
assert!(has_older(&plans));
assert!(blocked(&plans, false).is_some());
assert_eq!(blocked(&plans, true), None);
}
#[test]
fn an_unknown_zone_blocks_everything() {
let mut zoned = item(None, 0);
zoned.unresolved_zones = vec!["Somewhere/Imaginary".to_owned()];
let plans = [
plan(ImportIntent::Add, &item(None, 0), name),
plan(ImportIntent::Add, &zoned, name),
];
assert!(blocked(&plans, true).is_some());
}
#[test]
fn cancelling_only_unknown_events_has_nothing_to_do() {
let plans = [plan(ImportIntent::Cancel, &item(None, 0), name)];
assert_eq!(plans[0], Plan::NothingToCancel);
assert!(blocked(&plans, true).is_some());
}
#[test]
fn only_existing_events_are_confirmed() {
let mut other = item(None, 0);
other.uid = "new@example.com".to_owned();
let sent = confirmations(&[item(Some(0), 1), other]);
assert_eq!(
sent.into_iter().collect::<Vec<_>>(),
vec![("lunch@example.com".to_owned(), "\"7\"".to_owned())],
);
}
#[test]
fn a_start_is_shown_on_the_viewers_clock() {
assert_eq!(when(&item(None, 0)), "Wed 21 Oct 2026, 16:00");
let mut all_day = item(None, 0);
all_day.local_time = None;
assert_eq!(when(&all_day), "Wed 21 Oct 2026, all day");
}
#[test]
fn the_result_is_said_in_one_sentence() {
let mut added = item(None, 0);
added.outcome = Some(ImportOutcome::Created);
let mut replaced = item(Some(0), 1);
replaced.outcome = Some(ImportOutcome::Replaced);
assert_eq!(
done(&[added.clone(), added, replaced]),
"2 added, 1 replaced."
);
assert_eq!(done(&[]), "Nothing changed.");
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod dates;
pub mod details;
pub mod drag;
pub mod form;
pub mod import;
pub mod load;
pub mod menu;
pub mod month;
+42
View File
@@ -557,6 +557,48 @@ pub struct Overlay {
zone: Signal<String>,
}
/// An `.ics` file that was picked or dropped, waiting to be imported.
///
/// Provided by the shell, above both the sidebar that picks a file and the
/// view area whose dialog imports it.
#[derive(Clone, Copy)]
pub struct Importing {
file: RwSignal<Option<PickedFile>>,
}
#[derive(Clone, PartialEq)]
pub struct PickedFile {
pub name: String,
/// The contents, or why they could not be read.
pub text: Result<String, String>,
}
impl Default for Importing {
fn default() -> Self {
Self::new()
}
}
impl Importing {
pub fn new() -> Self {
Self {
file: RwSignal::new(None),
}
}
pub fn file(&self) -> Option<PickedFile> {
self.file.get()
}
pub fn open(&self, file: PickedFile) {
self.file.set(Some(file));
}
pub fn close(&self) {
self.file.set(None);
}
}
/// An event open in the form.
#[derive(Clone, PartialEq)]
pub struct Editing {
+90
View File
@@ -1341,6 +1341,96 @@ if (await spanishIn('personal') !== spanishBefore) {
problems.push('the series did not move back to Personal');
}
// Importing an .ics file, the way an emailed invitation arrives: picked in
// the sidebar, then updated by a later version of the same invitation, then
// cancelled by a file dropped on the calendar. The cancellation also leaves
// the calendars as the seed made them, so this can run twice.
const invitation = (method, sequence, summary, extra = '') => [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Shoot//EN', `METHOD:${method}`,
'BEGIN:VEVENT', 'UID:shoot-invite@example.com', 'DTSTAMP:20260801T120000Z',
'DTSTART:20260827T160000Z', 'DTEND:20260827T170000Z',
`SEQUENCE:${sequence}`, `SUMMARY:${summary}`, extra,
'END:VEVENT', 'END:VCALENDAR', '',
].filter((line) => line !== '').join('\r\n') + '\r\n';
const pick = async (name, text) => {
await page.setInputFiles('[data-testid="import-file"]', {
name, mimeType: 'text/calendar', buffer: Buffer.from(text),
});
await page.waitForSelector('[data-testid="import-plan"]', { timeout: 15000 });
};
const plansShown = () => page.locator('[data-testid="import-plan"]').allTextContents();
const finishImport = async () => {
await page.click('[data-testid="import-confirm"]');
await page.waitForSelector('[data-testid="import-done"]', { timeout: 15000 });
const said = (await page.locator('[data-testid="import-done"]').textContent()).trim();
await page.click('[data-testid="import-cancel"]');
await page.waitForSelector('[data-testid="import-dialog"]', { state: 'detached', timeout: 5000 });
await page.waitForTimeout(1200);
return said;
};
const invited = () => page.evaluate(async () => {
const reply = await fetch('/api/events?from=2026-08-20&to=2026-09-01');
const body = await reply.json();
return body.occurrences
.filter((o) => o.event.uid === 'shoot-invite@example.com')
.map((o) => o.event.summary);
});
await pick('invite.ics', invitation('REQUEST', 0, 'Imported lunch'));
const firstPlans = await plansShown();
if (JSON.stringify(firstPlans) !== '["New"]') problems.push(`a new invitation was planned as ${JSON.stringify(firstPlans)}`);
if (await page.locator('[data-testid="import-calendar"]').count() === 0) {
problems.push('importing a new event offered no calendar to put it in');
}
await shoot('import-preview');
const added = await finishImport();
console.log(` importing an invitation: ${added} ${JSON.stringify(await invited())}`);
if (added !== '1 added.') problems.push(`importing an invitation said "${added}"`);
await pick('update.ics', invitation('REQUEST', 1, 'Imported lunch, moved'));
const updatePlans = await plansShown();
if (!updatePlans[0]?.startsWith('Replaces the event in')) {
problems.push(`an update to an imported invitation was planned as ${JSON.stringify(updatePlans)}`);
}
const replaced = await finishImport();
const afterUpdate = await invited();
console.log(` importing its update: ${replaced} ${JSON.stringify(afterUpdate)}`);
if (JSON.stringify(afterUpdate) !== '["Imported lunch, moved"]') {
problems.push(`an update left ${JSON.stringify(afterUpdate)} rather than replacing the event`);
}
// Dropped, rather than picked: the other way a file arrives.
await page.evaluate((text) => {
const data = new DataTransfer();
data.items.add(new File([text], 'cancel.ics', { type: 'text/calendar' }));
document.querySelector('[data-testid="view-area"]')
.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
}, invitation('CANCEL', 2, 'Imported lunch, moved', 'STATUS:CANCELLED'));
await page.waitForSelector('[data-testid="import-plan"]', { timeout: 15000 });
const cancelPlans = await plansShown();
if (!cancelPlans[0]?.startsWith('Cancels the event in')) {
problems.push(`a dropped cancellation was planned as ${JSON.stringify(cancelPlans)}`);
}
const cancelled = await finishImport();
const afterCancel = await invited();
console.log(` dropping its cancellation: ${cancelled} ${JSON.stringify(afterCancel)}`);
if (afterCancel.length !== 0) problems.push(`a cancellation left ${JSON.stringify(afterCancel)}`);
// A zone nobody can resolve stops the import rather than guessing the time.
await pick('zoned.ics', [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Shoot//EN',
'BEGIN:VEVENT', 'UID:shoot-zoned@example.com', 'DTSTAMP:20260801T120000Z',
'DTSTART;TZID=Somewhere/Imaginary:20260827T090000', 'SUMMARY:Zoned',
'END:VEVENT', 'END:VCALENDAR', '',
].join('\r\n'));
const zoneHeld = (await page.locator('[data-testid="import-held"]').textContent()).trim();
if (!(await page.locator('[data-testid="import-confirm"]').isDisabled())) {
problems.push('an event in an unknown zone could still be imported');
}
console.log(` an unknown zone: "${zoneHeld}"`);
await page.keyboard.press('Escape');
await page.waitForSelector('[data-testid="import-dialog"]', { state: 'detached', timeout: 5000 });
await page.click('[data-testid="today"]');
await page.waitForTimeout(900);
await page.click('[data-testid="view-week"]');