CalDAV/CardDAV review fixes, round 3
- sync-collection with nresults 0 ignores the limit; it handed out the current token with no changes, so clients lost them - Migrations run in one transaction; a crash between steps no longer leaves a database the server cannot open - Deleting a user or room cancels and declines its meetings first, then rewrites addresses to the tombstone, inbox messages included - Deletion tombstones beyond the newest 1000 per collection are pruned (schema 15, pruned_seq); older sync tokens get valid-sync-token - Initial sync reads changes before members; UID lookups prefer the scheduling copy; a move onto itself is a no-op - MKCOL: 405 on a non-collection, slug and per-principal count limits, checked displayname, description, color and order values - Deleted users get the login challenge; PUT and GET parse off the async workers; import into a new account creates defaults first - Recurrence: rules without COUNT start near the window; rrule's grid bug on hour and minute limits is worked around by filtering them - vCard 3 marks the lowest PREF as preferred; generated lines are folded and TEXT drops control characters; more status reasons - Calendar UI shows events only, no tasks; replies have a busy guard; midnight ends show the right day; retry on fetch errors; sequence guards on dialog and share loads; month keys refocus the grid - Contacts: impossible dates refused, mailto encoded, hidden books filtered on the server, detail pane follows the list - Accessibility: tab panels and arrow keys, labelled inputs and dialogs, color radios with one tab stop, event counts on month cells - Tests for each change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
M.hearthforge-ci.toml
@@ -83,7 +83,7 @@ name = "lint"
run_sh = "cd project && just lint"
warn_on_fail = true
# `just test` covers server and api-types only, hence the extra web run.
# `just test` covers server, api-types and pimdav only, hence the extra web run.
[[steps]]
name = "test"
run_sh = "cd project && just test && cargo test -p web"
Mpimdav/src/bundle.rs
@@ -9,7 +9,8 @@ use std::collections::{HashMap, HashSet};
use sha2::{Digest, Sha256};
use crate::text::{
escape_text, logical_lines, name, param, param_parts, unescape_text, unfold, value, value_start,
escape_text, fold, logical_lines, name, param, param_parts, unescape_text, unfold, value,
value_start,
};
const PRODID: &str = "PRODID:-//dovenest//pimdav//EN";
@@ -80,7 +81,8 @@ pub fn calendar(objects: &[&str], name: Option<&str>, detail: Detail) -> String
// RFC 7986's NAME, and the X-WR-CALNAME most clients read instead.
if let Some(name) = name {
let name = escape_text(name);
out.push_str(&format!("NAME:{name}\r\nX-WR-CALNAME:{name}\r\n"));
out.push_str(&fold(&format!("NAME:{name}"), "\r\n"));
out.push_str(&fold(&format!("X-WR-CALNAME:{name}"), "\r\n"));
}
out + &zones + &body + "END:VCALENDAR\r\n"
}
Mpimdav/src/expand.rs
@@ -10,13 +10,13 @@ use calcard::icalendar::{
ICalendarParameterName, ICalendarPeriod, ICalendarProperty, ICalendarRecurrenceRule,
ICalendarSkip, ICalendarValue,
};
use chrono::{DateTime, NaiveDateTime, NaiveTime, TimeDelta, TimeZone, Utc, Weekday};
use chrono::{DateTime, NaiveDateTime, NaiveTime, TimeDelta, TimeZone, Timelike, Utc, Weekday};
use crate::zone::{Zone, Zones, add, add_local};
/// Occurrences one rule may generate before expansion gives up.
// ponytail: a FREQ=MINUTELY rule that started years ago hits this. Skip ahead
// for simple rules if that matters.
// ponytail: COUNT, MONTHLY and YEARLY rules iterate from DTSTART, so a long
// running one can hit this. Skip ahead for them if that matters.
const MAX_OCCURRENCES: usize = 1_000_000;
#[derive(Debug, Clone, PartialEq)]
@@ -342,19 +342,22 @@ fn expand_group(
}
let slack = slack + TimeDelta::days(1);
let (from, to) = (add(window.start, -slack), add(window.end, slack));
let to_local = mz.to_local(to);
let (from_local, to_local) = (mz.to_local(from), mz.to_local(to));
let mut excluded = HashSet::new();
for e in mc.properties(&ICalendarProperty::Exrule) {
let Some(rule) = e.values.first().and_then(rule) else {
continue;
};
for local in occurrences_capped(rule, &m, to_local, out) {
for local in occurrences_capped(rule, &m, from_local, to_local, out) {
excluded.insert(m.member_key(local, mz.to_utc(local)));
}
}
// ponytail: sub-daily rules iterate in wall time. A DST fall-back skips the
// repeated hour, and a spring-forward collapses a shifted instance into the
// next (COUNT=5 gives 4). Iterate in elapsed time, BYHOUR in local, to fix.
for rule in rules {
for local in occurrences_capped(rule, &m, to_local, out) {
for local in occurrences_capped(rule, &m, from_local, to_local, out) {
let utc = mz.to_utc(local);
if utc >= from {
set.entry(m.member_key(local, utc)).or_insert(Member {
@@ -455,6 +458,7 @@ fn push_overrides<'a>(
fn occurrences_capped(
rule: &ICalendarRecurrenceRule,
m: &Timing,
from_local: NaiveDateTime,
to_local: NaiveDateTime,
out: &mut Expansion,
) -> Vec<NaiveDateTime> {
@@ -465,7 +469,24 @@ fn occurrences_capped(
},
None => None,
};
let mut list: Vec<_> = occurrences(rule, m.start.local, until, to_local)
// Whole steps keep the period grid, so INTERVAL and BYxxx still line up.
let unit = match rule.freq {
ICalendarFrequency::Weekly => 7 * 86400,
ICalendarFrequency::Daily => 86400,
ICalendarFrequency::Hourly => 3600,
ICalendarFrequency::Minutely => 60,
ICalendarFrequency::Secondly => 1,
_ => 0,
};
let mut first = m.start.local;
if unit > 0 && rule.count.is_none() {
let step = unit * i64::from(rule.interval.unwrap_or(1).max(1));
let behind = (from_local - first).num_seconds();
if behind > 0 {
first += TimeDelta::seconds(behind / step * step);
}
}
let mut list: Vec<_> = occurrences(rule, first, until, to_local)
.take(MAX_OCCURRENCES + 1)
.collect();
if list.len() > MAX_OCCURRENCES {
@@ -613,8 +634,19 @@ pub(crate) fn occurrences(
}
})
.collect();
let interval = rule.interval.unwrap_or(1).max(1);
// rrule loses the INTERVAL grid when it jumps to the next allowed hour or
// minute and the interval does not divide 60. Filter those limits here.
let (hours, minutes) = match rule.freq {
ICalendarFrequency::Minutely if 60 % interval != 0 => (rule.byhour.clone(), Vec::new()),
ICalendarFrequency::Secondly if 60 % interval != 0 => {
(rule.byhour.clone(), rule.byminute.clone())
}
_ => (Vec::new(), Vec::new()),
};
let filtered = !(hours.is_empty() && minutes.is_empty());
let mut r = rrule::RRule::new(freq)
.interval(rule.interval.unwrap_or(1).max(1))
.interval(interval)
.week_start(rule.wkst.map_or(Weekday::Mon, Into::into))
.by_set_pos(rule.bysetpos.clone())
.by_month(&months.unwrap_or_default())
@@ -622,11 +654,20 @@ pub(crate) fn occurrences(
.by_year_day(rule.byyearday.clone())
.by_week_no(rule.byweekno.clone())
.by_weekday(weekdays)
.by_hour(rule.byhour.clone())
.by_minute(rule.byminute.clone())
.by_hour(if hours.is_empty() {
rule.byhour.clone()
} else {
Vec::new()
})
.by_minute(if minutes.is_empty() {
rule.byminute.clone()
} else {
Vec::new()
})
.by_second(rule.bysecond.clone());
// COUNT and UNTIL together are invalid. UNTIL is the safer bound.
if let Some(n) = rule.count.filter(|_| until.is_none()) {
let count = rule.count.filter(|_| until.is_none());
if let Some(n) = count.filter(|_| !filtered) {
r = r.count(n);
}
if let Some(u) = until {
@@ -639,5 +680,14 @@ pub(crate) fn occurrences(
.into_iter()
.flatten()
.map(|t| t.naive_utc())
.filter(move |t| {
(hours.is_empty() || hours.contains(&(t.hour() as u8)))
&& (minutes.is_empty() || minutes.contains(&(t.minute() as u8)))
})
.take(
count
.filter(|_| filtered)
.map_or(usize::MAX, |n| n as usize),
)
.take_while(move |t| *t <= end)
}
Mpimdav/src/freebusy.rs
@@ -260,17 +260,15 @@ pub fn request(body: &[u8]) -> Result<Request, Name> {
pub fn reply(periods: &[Period], req: &Request, attendee: &str, now: DateTime<Utc>) -> String {
// The values come from the request: no line breaks may get through.
let clean = |s: &str| s.chars().filter(|c| !c.is_control()).collect::<String>();
let mut props = format!(
"ORGANIZER:{}\r\nATTENDEE:{}\r\n",
clean(&req.organizer),
clean(attendee)
);
let fold = |line: String| crate::text::fold(&line, "\r\n");
let mut props = fold(format!("ORGANIZER:{}", clean(&req.organizer)))
+ &fold(format!("ATTENDEE:{}", clean(attendee)));
if let Some(uid) = &req.uid {
let uid = clean(uid)
.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,");
props.push_str(&format!("UID:{uid}\r\n"));
props.push_str(&fold(format!("UID:{uid}")));
}
render(periods, &req.range, now, "METHOD:REPLY\r\n", &props)
}
Mpimdav/src/render.rs
@@ -2,7 +2,7 @@
//! object, a part of it, or its recurrence set expanded (RFC 4791, 9.6;
//! RFC 6352, 10.4).
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use calcard::common::PartialDateTime;
use calcard::icalendar::{
@@ -405,6 +405,25 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
let v3 = version == VCardVersion::V3_0;
let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut out = String::with_capacity(text.len());
// vCard 3.0 has no ranks: the best ranked line of a name becomes the pref.
let mut best: HashMap<String, u32> = HashMap::new();
if v3 {
for raw in crate::text::logical_lines(text) {
let line = crate::text::unfold(raw);
let name = crate::text::name(&line);
let name = name.rsplit('.').next().unwrap_or_default().to_string();
for p in crate::text::param_parts(&line) {
if let Some(n) = p
.split_once('=')
.filter(|(k, _)| k.eq_ignore_ascii_case("PREF"))
.and_then(|(_, v)| v.trim().parse::<u32>().ok())
{
let b = best.entry(name.clone()).or_insert(n);
*b = (*b).min(n);
}
}
}
}
for raw in crate::text::logical_lines(text) {
let line = crate::text::unfold(raw);
let start = crate::text::value_start(&line);
@@ -426,7 +445,11 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
(false, "X-ADDRESSBOOKSERVER-MEMBER", _) => Some(("MEMBER", value)),
_ => None,
};
let params = pref_params(crate::text::param_parts(&line), v3);
let params = pref_params(
crate::text::param_parts(&line),
v3,
best.get(&name).copied().unwrap_or(1),
);
let (name, value) = renamed.unwrap_or((&name, value));
let mut new = format!("{group}{name}");
for p in ¶ms {
@@ -444,14 +467,14 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
out
}
/// `PREF=1` as a `pref` type for vCard 3.0, and back for 4.0.
fn pref_params(params: Vec<&str>, v3: bool) -> Vec<String> {
/// The lowest `PREF` as a `pref` type for vCard 3.0, and back for 4.0.
fn pref_params(params: Vec<&str>, v3: bool, lowest: u32) -> Vec<String> {
let mut out = Vec::with_capacity(params.len());
let mut pref = false;
for p in params {
let (key, value) = p.split_once('=').unwrap_or((p, ""));
match (v3, key.to_ascii_uppercase().as_str()) {
(true, "PREF") => pref |= value.trim() == "1",
(true, "PREF") => pref |= value.trim().parse() == Ok(lowest),
(false, "TYPE") => {
let (prefs, rest): (Vec<&str>, Vec<&str>) = value
.trim_matches('"')
Mpimdav/src/report.rs
@@ -180,4 +180,5 @@ fn limit(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
.and_then(|l| child(l, ns, "nresults"))
.map(|n| text(n).parse().map_err(|_| Refused::Invalid))
.transpose()
.map(|n| n.filter(|&n| n > 0))
}
Mpimdav/src/text.rs
@@ -133,4 +133,5 @@ pub(crate) fn escape_text(s: &str) -> String {
.replace(',', "\\,")
.replace('\n', "\\n")
.replace('\r', "")
.replace(|c: char| c.is_control() && c != '\t', "")
}
Mpimdav/src/xml.rs
@@ -293,14 +293,27 @@ pub fn status_line(code: u16) -> String {
let reason = match code {
200 => "OK",
201 => "Created",
204 => "No Content",
207 => "Multi-Status",
307 => "Temporary Redirect",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
412 => "Precondition Failed",
413 => "Payload Too Large",
415 => "Unsupported Media Type",
423 => "Locked",
424 => "Failed Dependency",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
507 => "Insufficient Storage",
_ => "",
};
format!("HTTP/1.1 {code} {reason}")
format!("HTTP/1.1 {code} {reason}").trim_end().to_owned()
}
pub fn document(root: &Element) -> String {
Mpimdav/tests/bundle.rs
@@ -27,6 +27,8 @@ fn feed_merges_objects_and_deduplicates_zones() {
assert!(full.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"));
assert!(full.contains(r"X-WR-CALNAME:Work\; home"));
assert!(full.contains(r"NAME:Work\; home"));
let long = bundle::calendar(&[&a], Some(&"x".repeat(200)), Detail::All);
assert!(long.lines().all(|l| l.len() <= 75), "{long}");
assert!(full.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n"));
assert_eq!(full.matches("BEGIN:VTIMEZONE").count(), 1);
assert!(full.contains("SUMMARY:Long\r\n folded\r\n"));
Mpimdav/tests/expand.rs
@@ -252,12 +252,73 @@ fn runaway_rule_is_truncated() {
let cal = ICalendar::parse(&ics).unwrap();
let out = expand(
&cal,
utc("2020-01-01T00:00")..utc("2020-01-02T00:00"),
utc("2000-01-01T00:00")..utc("2020-01-01T00:00"),
Zone::Utc,
);
assert!(out.truncated);
}
#[test]
fn old_sub_daily_rule_skips_ahead() {
let body = event(
"a",
"DTSTART:20200101T000000Z\r\nDTEND:20200101T000030Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7\r\n",
);
let got = instances(&body, "2026-03-01T10:00", "2026-03-01T10:20");
assert_eq!(got.len(), 3, "{got:?}");
assert!(got.iter().all(|g| {
let t = utc(&g[..16]);
(t - utc("2020-01-01T00:00")).num_minutes() % 7 == 0
}));
}
#[test]
fn sub_daily_rule_keeps_its_grid_across_hour_limits() {
let body = event(
"a",
"DTSTART:20200101T093000Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7;BYHOUR=9;BYMINUTE=0,30\r\n",
);
assert_eq!(
instances(&body, "2026-03-25T00:00", "2026-04-09T00:00"),
[
"2026-03-25T09:30/2026-03-25T09:30",
"2026-03-31T09:00/2026-03-31T09:00",
"2026-04-01T09:30/2026-04-01T09:30",
"2026-04-07T09:00/2026-04-07T09:00",
"2026-04-08T09:30/2026-04-08T09:30",
]
);
// COUNT counts the instances inside the hour limit only.
let counted = event(
"a",
"DTSTART:20260324T095000Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7;BYHOUR=9;COUNT=3\r\n",
);
assert_eq!(
instances(&counted, "2026-03-24T00:00", "2026-03-27T00:00"),
[
"2026-03-24T09:50/2026-03-24T09:50",
"2026-03-24T09:57/2026-03-24T09:57",
"2026-03-25T09:03/2026-03-25T09:03",
]
);
}
#[test]
fn old_biweekly_rule_keeps_its_grid() {
// 2026-03-02 is 321 weeks after DTSTART, so the week of 03-09 is the next one in the grid.
let body = event(
"a",
"DTSTART:20200106T090000Z\r\nDTEND:20200106T100000Z\r\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE\r\n",
);
assert_eq!(
instances(&body, "2026-03-01T00:00", "2026-03-20T00:00"),
[
"2026-03-09T09:00/2026-03-09T10:00",
"2026-03-11T09:00/2026-03-11T10:00",
]
);
}
#[test]
fn utc_until_on_an_all_day_series_keeps_its_day() {
let body = event(
Mpimdav/tests/report.rs
@@ -133,6 +133,13 @@ fn report_bodies() {
};
assert_eq!((token.as_str(), limit), ("", Some(5)));
let body = br#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level>
<d:limit><d:nresults>0</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
let Ok(Report::SyncCollection { limit, .. }) = parse(body) else {
panic!();
};
assert_eq!(limit, None);
let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:filter><c:comp-filter name="VEVENT"/></c:filter></c:calendar-query>"#;
assert_eq!(
parse(body).unwrap_err(),
@@ -238,6 +245,12 @@ fn groups_and_companies_in_apple_forms() {
"{back}"
);
let ranked = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:r\r\nFN:R\r\n\
EMAIL;PREF=3:c@example.com\r\nEMAIL;PREF=2:b@example.com\r\nEND:VCARD\r\n";
let v3 = address_data(ranked, &to(V3_0));
assert!(v3.contains("EMAIL;TYPE=pref:b@example.com"), "{v3}");
assert!(v3.contains("EMAIL:c@example.com"), "{v3}");
let org = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:ACME\r\nKIND:org\r\nEND:VCARD\r\n";
assert!(address_data(org, &to(V3_0)).contains("X-ABSHOWAS:COMPANY\r\n"));
let apple = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:o\r\nFN:ACME\r\nX-ABSHOWAS:COMPANY\r\n\
Mserver/src/api/admin.rs
@@ -15,10 +15,10 @@ use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{
blocking, hash_password, root_info, validate_account_name, validate_password,
};
use crate::api::pim::principal_href;
use crate::api::pim::{INBOX, principal_href};
use crate::api::shares;
use crate::api::{pim_api, pim_schedule};
use crate::db::{PimPrincipal, RootRow, UserType};
use crate::db::{PimKind, PimPrincipal, RootRow, UserType};
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -225,7 +225,10 @@ pub async fn delete_user(
let _lock = pim_schedule::LOCK.lock().await;
let pid = state.db.principal_of(id).await?;
let ops = match state.db.pim_principal_by_id(pid).await? {
Some(p) => pim_schedule::forget(&state, &p).await?,
Some(p) => {
retract_all(&state, &p).await?;
pim_schedule::forget(&state, &p).await?
}
None => Vec::new(),
};
if !state.db.delete_user(id, &ops).await? {
@@ -242,6 +245,33 @@ pub async fn delete_user(
// Shares
// ---------------------------------------------------------------------------
/// Commits the cancellations and declines for everything `p` owns, before
/// `forget`. `deliver` writes Put ops on attendee copies and inbox messages,
/// and `forget` builds its ops from stored data. In one transaction its Puts
/// would overwrite the cancellations, and the new inbox messages would keep
/// the real address. Hold the scheduling lock.
async fn retract_all(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
let dir = pim_schedule::Directory::load(state).await?;
let ids: Vec<i64> = state
.db
.pim_collections(p.id, PimKind::Calendar)
.await?
.into_iter()
.filter(|c| c.slug != INBOX)
.map(|c| c.id)
.collect();
match pim_schedule::retract(state, &dir, p, &ids).await? {
Ok(ops) => state.db.pim_apply(&ops).await?,
Err(_) => {
return Err(ApiError::new(
StatusCode::CONFLICT,
"the meetings cannot be cancelled",
));
}
}
Ok(())
}
/// GET /api/admin/shares — every share on the server with its creator.
///
/// Answers with the full share tokens, which the admin view offers as copy
@@ -400,6 +430,7 @@ pub async fn delete_room(
else {
return Err(room_not_found());
};
retract_all(&state, &room).await?;
let ops = pim_schedule::forget(&state, &room).await?;
if !state.db.delete_room(id, &ops).await? {
return Err(room_not_found());
Mserver/src/api/pim.rs
@@ -53,6 +53,11 @@ use crate::error::{ApiError, AppState};
/// Largest object a PUT may store. Contacts carry photos inline.
const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
const MAX_SLUG: usize = 255;
const MAX_COLLECTIONS: usize = 100;
const MAX_DISPLAYNAME: usize = 256;
const MAX_DESCRIPTION: usize = 1024;
/// Largest XML request body.
const MAX_XML_SIZE: usize = 1024 * 1024;
@@ -236,7 +241,7 @@ struct Col {
async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
let Some(user) = state.db.find_user_by_id(user_id).await? else {
return Ok(status(StatusCode::UNAUTHORIZED));
return Ok(super::dav::challenge());
};
let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
let Some(target) = parse_target(path) else {
@@ -830,13 +835,10 @@ pub(super) async fn delete_own(
.get(owner)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let w = Writer::owner(&owner);
let mut ops = Vec::new();
for (_, data) in db.pim_objects_with_data(col.id).await? {
if let Ok(more) = pim_schedule::delete(state, &dir, &w, &data, true).await? {
ops.extend(more);
}
}
let mut ops = match pim_schedule::retract(state, &dir, &owner, &[col.id]).await? {
Ok(ops) => ops,
Err(refused) => return Ok(Err(refused)),
};
// The cancellations commit with the delete, so no event goes without
// its attendees hearing of it.
ops.push(PimOp::DeleteCollection(col.id));
@@ -1581,7 +1583,7 @@ impl Cx<'_> {
async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply {
let Target::Collection(kind, _, slug) = target else {
return Ok(status(StatusCode::FORBIDDEN));
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let space = self.space();
if !space.mine {
@@ -1597,6 +1599,17 @@ impl Cx<'_> {
// Names the home shows for lent and generated collections.
if slug.starts_with(SHARED_PREFIX)
|| [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
|| slug.len() > MAX_SLUG
{
return Ok(status(StatusCode::FORBIDDEN));
}
if self
.state
.db
.pim_collections(self.me.pid, *kind)
.await?
.len()
>= MAX_COLLECTIONS
{
return Ok(status(StatusCode::FORBIDDEN));
}
@@ -1802,6 +1815,10 @@ fn apply(
patch
}
fn is_color(v: &str) -> bool {
matches!(v.len(), 7 | 9) && v.starts_with('#') && v[1..].bytes().all(|b| b.is_ascii_hexdigit())
}
/// Sets one of a collection's own properties. `None` if it is none of them,
/// `Some(valid)` otherwise.
fn set_own(
@@ -1813,26 +1830,48 @@ fn set_own(
) -> Option<bool> {
let cal = kind == PimKind::Calendar;
let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
let short =
|v: &Option<String>, max: usize| v.as_ref().is_none_or(|v| v.chars().count() <= max);
Some(match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => {
col.displayname = value();
true
let v = value();
let valid = short(&v, MAX_DISPLAYNAME);
if valid {
col.displayname = v;
}
valid
}
(CALDAV, "calendar-description") if cal => {
col.description = value();
true
let v = value();
let valid = short(&v, MAX_DESCRIPTION);
if valid {
col.description = v;
}
valid
}
(CARDDAV, "addressbook-description") if !cal => {
col.description = value();
true
let v = value();
let valid = short(&v, MAX_DESCRIPTION);
if valid {
col.description = v;
}
valid
}
(APPLE, "calendar-color") if cal => {
col.color = value();
true
let v = value();
let valid = v.as_deref().is_none_or(is_color);
if valid {
col.color = v;
}
valid
}
(APPLE, "calendar-order") if cal => {
col.sort_order = value();
true
let v = value();
let valid = v.as_deref().is_none_or(|v| v.parse::<i64>().is_ok());
if valid {
col.sort_order = v;
}
valid
}
(CALDAV, "calendar-timezone") if cal => {
let tz = value();
@@ -1929,7 +1968,10 @@ impl Cx<'_> {
props: None,
version: Some(render::accepted_version(accept)),
};
data = render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes();
data = blocking(move || -> Result<_, ApiError> {
Ok(render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes())
})
.await?;
}
let body = if head {
Body::empty()
@@ -1986,22 +2028,28 @@ impl Cx<'_> {
let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
};
let parsed = match kind {
PimKind::Calendar => {
let supported: Vec<&str> = col.components.split(',').collect();
object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
}
PimKind::AddressBook => object::vcard(&data)
.map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into())),
};
let (kind_c, components, name_c) = (*kind, col.components.clone(), name.clone());
let (parsed, stamped, data) = blocking(move || -> Result<_, ApiError> {
let parsed = match kind_c {
PimKind::Calendar => {
let supported: Vec<&str> = components.split(',').collect();
object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
}
PimKind::AddressBook => {
object::vcard(&data).map(|uid| (uid.unwrap_or(name_c), "VCARD".into()))
}
};
let stamped = match (&parsed, kind_c) {
(Ok(_), PimKind::Calendar) => object::with_dtstamp(&data, chrono::Utc::now()),
_ => None,
};
Ok((parsed, stamped, data))
})
.await?;
let (uid, component) = match parsed {
Ok(v) => v,
Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
};
let stamped = match kind {
PimKind::Calendar => object::with_dtstamp(&data, chrono::Utc::now()),
PimKind::AddressBook => None,
};
let data = stamped.as_deref().unwrap_or(&data);
let _lock = pim_schedule::LOCK.lock().await;
@@ -2351,7 +2399,12 @@ impl Cx<'_> {
Some((id, seq)) if id == col.id && generated(id) && seq == col.seq => {
Some(seq)
}
Some((id, seq)) if id == col.id && !generated(id) && seq <= col.seq => {
Some((id, seq))
if id == col.id
&& !generated(id)
&& seq <= col.seq
&& seq >= self.state.db.pim_pruned_seq(id).await? =>
{
Some(seq)
}
_ => {
@@ -2362,20 +2415,22 @@ impl Cx<'_> {
// A generated collection has no change log to resume a cut
// answer from. It is small, so it always answers in full.
let limit = limit.filter(|_| !generated(col.id));
// The changes come first: a write between the two reads then
// only makes the next sync refetch a member.
let mut changes = match generated(col.id) {
true => Vec::new(),
false => self.state.db.pim_changes(col.id, since).await?,
};
// An initial sync reads every member at once, not one per change.
let mut members = match since {
None => Some(self.member_map(&col).await?),
Some(_) => None,
};
let mut changes = match (&members, generated(col.id)) {
(Some(m), true) => {
let mut names: Vec<_> = m.keys().cloned().collect();
names.sort();
names.into_iter().map(|n| (n, col.seq, false)).collect()
}
(None, true) => Vec::new(),
(_, false) => self.state.db.pim_changes(col.id, since).await?,
};
if let (Some(m), true) = (&members, generated(col.id)) {
let mut names: Vec<_> = m.keys().cloned().collect();
names.sort();
changes = names.into_iter().map(|n| (n, col.seq, false)).collect();
}
let truncated = limit.is_some_and(|n| changes.len() > n);
if let Some(n) = limit {
changes.truncate(n);
@@ -2663,8 +2718,8 @@ impl Cx<'_> {
if to.access < Access::Write || to.c.slug == INBOX {
return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
}
// UIDs are unique per owner and a meeting stays in its organizer's
// calendars, so an object never changes owner. Clients fall back to
// A meeting stays in its organizer's calendars (`elsewhere` allows one
// scheduling object per UID and principal), so an object never changes owner. Clients fall back to
// PUT and DELETE, which schedule as usual.
if !from.owner.eq_ignore_ascii_case(&to.owner) {
return Ok(status(StatusCode::FORBIDDEN));
Mserver/src/api/pim_api.rs
@@ -867,8 +867,9 @@ pub async fn import_new(
let color = own_color
.filter(|c| valid_color(c))
.or(q.color.filter(|c| valid_color(c)));
let info = create_collection(&state, &auth.user, kind, &name, color, None, &[]).await?;
let pid = state.db.principal_of(auth.user.id).await?;
state.db.pim_ensure_defaults(pid).await?;
let info = create_collection(&state, &auth.user, kind, &name, color, None, &[]).await?;
let (_, _, col) = state
.db
.pim_collection_by_id(info.id)
@@ -878,7 +879,12 @@ pub async fn import_new(
let keep = matches!(&result, Ok(r) if r.created + r.updated > 0);
if !keep {
// Empty and never lent or synced: nothing to cancel, nobody to tell.
let _ = delete_own(&state, pid, kind, &col).await?;
if delete_own(&state, pid, kind, &col).await?.is_err() {
return Err(ApiError::new(
StatusCode::CONFLICT,
"the empty collection could not be removed",
));
}
}
Ok(Json(PimImportNew {
collection: keep.then_some(info),
Mserver/src/api/pim_schedule.rs
@@ -334,6 +334,27 @@ pub(crate) async fn delete(
removed(state, dir, w, &old, role, reply).await
}
/// The writes that cancel or decline every object of the collections for
/// their attendees, as deleting each object would. Hold [`LOCK`].
pub(crate) async fn retract(
state: &AppState,
dir: &Directory,
owner: &PimPrincipal,
collection_ids: &[i64],
) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
let w = Writer::owner(owner);
let mut ops = Vec::new();
for &id in collection_ids {
for (_, data) in state.db.pim_objects_with_data(id).await? {
match delete(state, dir, &w, &data, true).await? {
Ok(more) => ops.extend(more),
Err(refused) => return Ok(Err(refused)),
}
}
}
Ok(Ok(ops))
}
/// An organizer object going away cancels; an attendee copy declines.
async fn removed(
state: &AppState,
Mserver/src/api/pim_views.rs
@@ -18,7 +18,9 @@ use axum::Json;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
use pimdav::calcard::icalendar::{ICalendar, ICalendarParticipationStatus, ICalendarProperty};
use pimdav::calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarParticipationStatus, ICalendarProperty,
};
use pimdav::expand::expand;
use pimdav::itip::{self, Role};
use pimdav::principal::UserType;
@@ -156,6 +158,10 @@ pub async fn instances(
truncated |= exp.truncated;
let mut infos: HashMap<usize, EventInfo> = HashMap::new();
for i in exp.instances {
if cal.components[i.component].component_type != ICalendarComponentType::VEvent
{
continue;
}
if out.len() == MAX_INSTANCES {
truncated = true;
break 'all;
@@ -441,7 +447,7 @@ pub async fn invitations(
}
let instances = expand(&cal, window.clone(), zone.clone()).instances;
for (index, c) in cal.components.iter().enumerate() {
if !view::is_item(c) {
if c.component_type != ICalendarComponentType::VEvent {
continue;
}
let info = view::event_info(&cal, index, &owns);
Mserver/src/db.rs
@@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 14;
const SCHEMA_VERSION: i64 = 15;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -411,6 +411,7 @@ impl Db {
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let tx = conn.unchecked_transaction()?;
if version < 1 {
conn.execute_batch(SCHEMA_V1)?;
}
@@ -632,11 +633,17 @@ impl Db {
REFERENCES pim_collections(id) ON DELETE SET NULL",
)?;
}
if version < 15 {
// Sync tokens below this are refused: the tombstones they need are gone.
conn.execute_batch(
"ALTER TABLE pim_collections ADD COLUMN pruned_seq INTEGER NOT NULL DEFAULT 0",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
)?;
Ok(())
tx.commit()
}
// ---------- users ----------
@@ -1218,7 +1225,6 @@ impl Db {
Ok(user)
}
/// Delete a user. `false` means no row matched.
/// The stored spelling of the name, which a Basic login may differ from
/// in case.
pub async fn user_name(&self, id: i64) -> DbResult<Option<String>> {
@@ -1228,7 +1234,8 @@ impl Db {
}
/// Deletes an account after `ops`, in one transaction. The ops make other
/// principals' objects forget it (`pim_schedule::forget`).
/// principals' objects forget it (`pim_schedule::forget`). `false` means
/// no row matched.
pub async fn delete_user(&self, id: i64, ops: &[PimOp]) -> DbResult<bool> {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
@@ -1321,9 +1328,42 @@ impl Db {
"DELETE FROM share_unlocks WHERE created_at < ?1",
[stamp(chrono::Duration::days(UNLOCK_MAX_AGE_DAYS))],
)?;
drop(c);
self.pim_prune(PIM_TOMBSTONES_KEPT).await?;
Ok((shares, unlocks))
}
/// Keeps the newest `keep` deletion records of each collection.
pub async fn pim_prune(&self, keep: i64) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
"UPDATE pim_collections SET pruned_seq = MAX(pruned_seq, old.seq)
FROM (SELECT collection_id, MAX(seq) AS seq FROM (
SELECT collection_id, seq, ROW_NUMBER() OVER (
PARTITION BY collection_id ORDER BY seq DESC) AS rn
FROM pim_changes WHERE deleted != 0)
WHERE rn > ?1 GROUP BY collection_id) AS old
WHERE id = old.collection_id",
[keep],
)?;
c.execute(
"DELETE FROM pim_changes WHERE deleted != 0 AND seq <= (
SELECT pruned_seq FROM pim_collections WHERE id = collection_id)",
[],
)?;
Ok(())
}
/// Sync tokens at or below this seq are no longer answerable.
pub async fn pim_pruned_seq(&self, collection_id: i64) -> DbResult<i64> {
let c = self.conn.lock().await;
c.query_row(
"SELECT pruned_seq FROM pim_collections WHERE id = ?1",
[collection_id],
|r| r.get(0),
)
}
/// Whether `token` is a live unlock for `share_id`.
///
/// The share id is part of the lookup, so an unlock for one share cannot
@@ -1559,7 +1599,7 @@ impl Db {
o.schedule_tag, o.data, o.collection_id
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.principal_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox' AND o.uid = ?2
ORDER BY o.id LIMIT 1",
ORDER BY o.schedule_tag IS NULL, o.id LIMIT 1",
)?;
stmt.query_row(params![principal_id, uid], |r| {
Ok((r.get(8)?, map_pim_object(r)?, r.get(7)?))
@@ -1637,9 +1677,10 @@ impl Db {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
let n = tx.execute(
"INSERT OR IGNORE INTO pim_collections (principal_id, kind, slug, displayname,
"INSERT INTO pim_collections (principal_id, kind, slug, displayname,
description, color, timezone, sort_order, components, transparent, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT (principal_id, kind, slug) DO NOTHING",
params![
principal_id,
kind.as_str(),
@@ -1820,6 +1861,9 @@ impl Db {
overwrite: bool,
cond: &Precondition,
) -> DbResult<PimWrite> {
if from == to && name == to_name {
return Ok(PimWrite::Updated);
}
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
let source: Option<(String, String)> = tx
@@ -2179,7 +2223,7 @@ impl Db {
/// `(collection id, object, data)` of the calendar objects of other
/// principals whose text holds one of `needles`, compared without ASCII
/// case. The inbox is left out: its messages are only a record.
/// case.
pub async fn pim_objects_mentioning(
&self,
principal_id: i64,
@@ -2192,8 +2236,7 @@ impl Db {
let mut stmt = c.prepare(&format!(
"SELECT o.collection_id, {PIM_OBJECT_COLS_O}, o.data
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.principal_id != ?1 AND c.kind = 'cal' AND c.slug != 'inbox'
AND ({})",
WHERE c.principal_id != ?1 AND c.kind = 'cal' AND ({})",
any.join(" OR ")
))?;
let lower: Vec<String> = needles.iter().map(|n| n.to_ascii_lowercase()).collect();
@@ -2690,6 +2733,9 @@ fn now() -> String {
stamp(chrono::Duration::zero())
}
/// Deletion records kept per collection for sync tokens.
const PIM_TOMBSTONES_KEPT: i64 = 1000;
const SCHEMA_V1: &str = r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Mserver/tests/api_pim.rs
@@ -1641,3 +1641,188 @@ async fn other_accounts_get_no_client_properties_or_loans_of_disabled_owners() {
assert_eq!(r.status, StatusCode::OK);
assert_eq!(status().await.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn pruned_tombstones_invalidate_old_sync_tokens() {
let (env, auth) = setup().await;
let sync = |token: &str| {
format!(
r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
)
};
let obj = |n: &str| format!("{CAL}{n}.ics");
let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
for n in ["a", "b", "c"] {
put(&env, &auth, &obj(n), &todo(n)).await;
}
let old = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync("")).await);
for n in ["a", "b", "c"] {
req(&env, "DELETE", &obj(n), &auth, &[], "").await;
}
let mid = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await);
put(&env, &auth, &obj("d"), &todo("d")).await;
req(&env, "DELETE", &obj("d"), &auth, &[], "").await;
env.state.db.pim_prune(2).await.unwrap();
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&mid)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
}
#[tokio::test]
async fn mkcol_checks_target_and_values() {
let (env, auth) = setup().await;
let r = req(&env, "MKCOL", &format!("{CAL}x.ics"), &auth, &[], "").await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
let body = |color: &str| {
format!(
r#"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop><a:calendar-color>{color}</a:calendar-color></d:prop></d:set></c:mkcalendar>"#
)
};
let work = "/pim/calendars/alice/work/";
let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("red")).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("#FF8800AA")).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
async fn alice_pid(env: &Env) -> i64 {
let user = env
.state
.db
.find_user_by_name(ALICE)
.await
.unwrap()
.unwrap();
env.state.db.principal_of(user.id).await.unwrap()
}
#[tokio::test]
async fn a_taken_slug_leaves_the_first_collection_alone() {
use server::db::{PimCollection, PimKind};
let (env, _) = setup().await;
let db = &env.state.db;
let pid = alice_pid(&env).await;
let make = |name: &str| PimCollection {
slug: "work".into(),
displayname: Some(name.into()),
components: "VEVENT".into(),
..Default::default()
};
let kind = PimKind::Calendar;
assert!(
db.pim_create_collection(pid, kind, &make("First"), &[])
.await
.unwrap()
);
assert!(
!db.pim_create_collection(pid, kind, &make("Second"), &[])
.await
.unwrap()
);
let kept = db.pim_collection(pid, kind, "work").await.unwrap().unwrap();
assert_eq!(kept.displayname.as_deref(), Some("First"));
}
#[tokio::test]
async fn moving_an_object_onto_itself_changes_nothing() {
use server::db::{PimCollection, PimKind, PimObject, PimOp, PimWrite, Precondition};
let (env, _) = setup().await;
let db = &env.state.db;
let pid = alice_pid(&env).await;
db.pim_ensure_defaults(pid).await.unwrap();
let col: PimCollection = db
.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap();
let obj = PimObject {
name: "a.ics".into(),
uid: "a".into(),
component: "VEVENT".into(),
etag: "\"e\"".into(),
..Default::default()
};
db.pim_apply(&[PimOp::Put {
collection_id: col.id,
obj,
data: b"data".to_vec(),
}])
.await
.unwrap();
let before = db
.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap()
.seq;
let none = Precondition {
if_match: None,
if_none_match: None,
};
let r = db
.pim_move_object(col.id, "a.ics", col.id, "a.ics", true, &none)
.await
.unwrap();
assert_eq!(r, PimWrite::Updated);
let (_, data) = db.pim_object(col.id, "a.ics").await.unwrap().unwrap();
assert_eq!(data, b"data");
let changes = db.pim_changes(col.id, Some(before)).await.unwrap();
assert!(changes.is_empty(), "{changes:?}");
}
#[tokio::test]
async fn a_deleted_user_gets_the_login_challenge() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, ALICE, PW, &[]).await;
let auth = basic(ALICE, PW);
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let id = user_id(&admin, ALICE).await;
let r = admin.delete(&format!("/api/admin/users/{id}")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("www-authenticate").is_some());
}
#[tokio::test]
async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
use server::db::PimKind;
let (env, auth) = setup().await;
let long = format!("/pim/calendars/alice/{}/", "a".repeat(256));
let r = req(&env, "MKCALENDAR", &long, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let ok = format!("/pim/calendars/alice/{}/", "a".repeat(255));
let r = req(&env, "MKCALENDAR", &ok, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
// The cap counts the default calendar and the inbox as well.
let mut n = 0;
loop {
let r = req(&env, "MKCALENDAR", &format!("{HOME}c{n}/"), &auth, &[], "").await;
if r.status != StatusCode::CREATED {
assert_eq!(r.status, StatusCode::FORBIDDEN);
break;
}
n += 1;
assert!(n < 100, "no limit");
}
let pid = alice_pid(&env).await;
let all = env.state.db.pim_collections(pid, PimKind::Calendar).await;
assert_eq!(all.unwrap().len(), 100);
let r = req(
&env,
"MKCOL",
"/pim/addressbooks/alice/other/",
&auth,
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
Mserver/tests/api_pim_io.rs
@@ -503,3 +503,35 @@ async fn share_candidates_leave_out_owner_borrowers_and_disabled() {
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_skipped_import_into_a_new_user_leaves_only_the_default_calendar() {
use server::db::PimKind;
let io = Io::new().await;
let client = login(&io.env, "bob", PW).await;
let broken = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:no start\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let r = client
.raw(
Method::POST,
"/api/pim/import?kind=calendar&file=bad.ics",
&[("content-type", "text/calendar")],
broken.as_bytes().to_vec(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["collection"], Value::Null);
assert_eq!(r.json()["skipped_total"], 1);
let db = &io.env.state.db;
let bob = db.find_user_by_name("bob").await.unwrap().unwrap();
let pid = db.principal_of(bob.id).await.unwrap();
let slugs: Vec<_> = db
.pim_collections(pid, PimKind::Calendar)
.await
.unwrap()
.into_iter()
.map(|c| c.slug)
.filter(|s| s != "inbox" && s != "outbox")
.collect();
assert_eq!(slugs, ["default"]);
}
Mserver/tests/api_pim_schedule.rs
@@ -1123,3 +1123,77 @@ async fn imports_on_a_plain_loan_skip_meetings() {
let org = pim.get("alice", ALICE_EVENT).await;
assert!(org.contains("DTSTART:20260301T100000Z"), "{org}");
}
#[tokio::test]
async fn deleting_an_organizer_cancels_and_forgets_it() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let id = user_id(&pim.admin, "alice").await;
let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await;
assert_eq!(r.status, StatusCode::OK);
let (_, copy) = pim.copy("bob").await;
let copy = unfold(©.text());
assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
assert!(!copy.contains("alice@dovenest.invalid"), "{copy}");
assert!(copy.contains("@deleted."), "{copy}");
let inbox = pim.inbox("bob").await;
assert!(!inbox.is_empty());
assert!(
inbox.iter().all(|m| !m.contains("alice@dovenest.invalid")),
"{inbox:?}"
);
}
#[tokio::test]
async fn find_uid_prefers_the_scheduling_copy() {
use server::db::{PimCollection, PimKind, PimObject, PimOp};
let pim = Pim::new().await;
let db = &pim.env.state.db;
let pid = db
.principal_of(user_id(&pim.admin, "bob").await)
.await
.unwrap();
db.pim_ensure_defaults(pid).await.unwrap();
let work = PimCollection {
slug: "work".into(),
components: "VEVENT".into(),
..Default::default()
};
assert!(
db.pim_create_collection(pid, PimKind::Calendar, &work, &[])
.await
.unwrap()
);
let default = db
.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap();
let work = db
.pim_collection(pid, PimKind::Calendar, "work")
.await
.unwrap()
.unwrap();
let put = |collection_id: i64, name: &str, schedule_tag: Option<&str>| PimOp::Put {
collection_id,
obj: PimObject {
name: name.into(),
uid: "meet-1".into(),
component: "VEVENT".into(),
etag: "\"e\"".into(),
schedule_tag: schedule_tag.map(Into::into),
..Default::default()
},
data: b"x".to_vec(),
};
// The plain copy is older, so only the schedule tag can rank the other first.
db.pim_apply(&[put(default.id, "plain.ics", None)])
.await
.unwrap();
db.pim_apply(&[put(work.id, "meeting.ics", Some("\"s\""))])
.await
.unwrap();
let (id, obj, _) = db.pim_find_uid(pid, "meet-1").await.unwrap().unwrap();
assert_eq!((id, obj.name.as_str()), (work.id, "meeting.ics"));
}
Mserver/tests/api_pim_ui.rs
@@ -588,3 +588,19 @@ async fn invitations_are_listed_and_answered() {
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn tasks_are_not_instances() {
let ui = Ui::new().await;
ui.put(
"alice",
"/pim/calendars/alice/default/todo.ics",
&ics("BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260110T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n"),
)
.await;
let r = ui
.alice
.get("/api/pim/instances?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z")
.await;
assert!(r.json()["instances"].as_array().unwrap().is_empty());
}
Mweb/src/api.rs
@@ -1376,12 +1376,23 @@ pub fn pim_photo_url(id: i64, name: &str) -> String {
)
}
/// The contacts of the address books `collections`.
pub fn pim_contacts(
q: &str,
collections: &[i64],
) -> impl std::future::Future<Output = Result<Vec<PimContact>, ApiError>> {
let ids = collections
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
request(
"GET",
format!("{PIM_CONTACTS}?{P_Q}={}", enc(q)),
format!(
"{PIM_CONTACTS}?{P_Q}={}&{}={ids}",
enc(q),
api_types::P_COLLECTIONS
),
None::<()>,
)
}
Mweb/src/components/modal.rs
@@ -4,8 +4,12 @@
//! (`::backdrop`), the focus trap, inertness for the rest of the page, the
//! top layer, and Escape-to-close. None of that is hand-rolled here.
use std::sync::atomic::{AtomicU32, Ordering};
use leptos::prelude::*;
static NEXT_TITLE: AtomicU32 = AtomicU32::new(0);
#[component]
pub fn Modal(
/// Classes for the card, appended to `modal` (e.g. `"card picker"`).
@@ -14,11 +18,28 @@ pub fn Modal(
/// Runs when the dialog asks to close: Escape, or a backdrop click.
/// The owner clears its own state, which unmounts the dialog.
on_close: Callback<()>,
/// Id of the title element. Default: the first `.modal-title` present on
/// mount.
#[prop(optional, into)]
labelled_by: Option<String>,
children: Children,
) -> impl IntoView {
let dialog: NodeRef<leptos::html::Dialog> = NodeRef::new();
Effect::new(move |_| {
if let Some(el) = dialog.get() {
let title = labelled_by.clone().or_else(|| {
let t = el.query_selector(".modal-title").ok().flatten()?;
if t.id().is_empty() {
t.set_id(&format!(
"modal-title-{}",
NEXT_TITLE.fetch_add(1, Ordering::Relaxed)
));
}
Some(t.id())
});
if let Some(id) = title {
let _ = el.set_attribute("aria-labelledby", &id);
}
// `show_modal`, never `show`: only the modal form gets the top
// layer, the backdrop and the focus trap.
let _ = el.show_modal();
Mweb/src/i18n.rs
@@ -481,6 +481,8 @@ i18n_keys! {
PIM_DETAIL_FULL = "pim_detail_full" => "All details",
PIM_DIRECTORY = "pim_directory" => "Directory",
PIM_EMAIL = "pim_email" => "Email",
PIM_EVENTS_N = "pim_events_n" => "{} events",
PIM_EVENTS_ONE = "pim_events_one" => "1 event",
PIM_EXPORT = "pim_export" => "Export",
PIM_FEEDS_OF = "pim_feeds_of" => "Feeds of {}",
PIM_FILE = "pim_file" => "File",
@@ -1273,6 +1275,8 @@ const DE: &[(&str, &str)] = &[
("pim_detail_full", "Alle Details"),
("pim_directory", "Verzeichnis"),
("pim_email", "E-Mail"),
("pim_events_n", "{} Termine"),
("pim_events_one", "1 Termin"),
("pim_export", "Exportieren"),
("pim_feeds_of", "Feeds von {}"),
("pim_file", "Datei"),
@@ -2242,6 +2246,8 @@ const FR: &[(&str, &str)] = &[
("pim_detail_full", "Tous les détails"),
("pim_directory", "Annuaire"),
("pim_email", "E-mail"),
("pim_events_n", "{} événements"),
("pim_events_one", "1 événement"),
("pim_export", "Exporter"),
("pim_feeds_of", "Flux de {}"),
("pim_file", "Fichier"),
Mweb/src/views/calendar.rs
@@ -7,7 +7,7 @@
use std::collections::{HashMap, HashSet};
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::spawn_local;
use crate::api::{
@@ -87,6 +87,14 @@ pub struct Span {
}
impl Span {
/// The day and minute the instance ends, midnight as 00:00 of the next day.
fn end_at(&self) -> (i64, u32) {
match self.end_min {
1440 => (self.last + 1, 0),
m => (self.last, m),
}
}
/// A bar across days, not a line with a time.
fn is_bar(&self) -> bool {
self.all_day || self.first != self.last
@@ -151,7 +159,7 @@ pub fn layout_week(spans: &[Span], week: i64, lines: usize) -> WeekLayout {
}
for c in c0..=c1 {
used[line][c] = true;
count[c] += 1;
count[c] = count[c].max(line + 1);
}
placed.push((i, c0, c1, line));
}
@@ -221,21 +229,37 @@ pub fn when_on(s: &Span, day: i64) -> When {
pub fn split_links(text: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut rest = text;
while let Some(at) = ["https://", "http://"]
let mut from = 0;
while let Some((at, scheme)) = ["https://", "http://"]
.iter()
.filter_map(|p| rest.find(p))
.filter_map(|p| rest[from..].find(p).map(|i| (from + i, p.len())))
.min()
{
let end = rest[at..]
.find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
.map_or(rest.len(), |e| at + e);
let link = rest[at..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
let link_end = at + link.len();
let mut link = &rest[at..end];
loop {
let t = link.trim_end_matches(['.', ',', ';', ':', '!', '?']);
let t = match t.strip_suffix(')') {
Some(u) if t.matches(')').count() > t.matches('(').count() => u,
_ => t,
};
if t.len() == link.len() {
break;
}
link = t;
}
if link.len() <= scheme {
from = at + scheme;
continue;
}
if at > 0 {
out.push((rest[..at].to_string(), false));
}
out.push((link.to_string(), true));
rest = &rest[link_end..];
rest = &rest[at + link.len()..];
from = 0;
}
if !rest.is_empty() {
out.push((rest.to_string(), false));
@@ -350,13 +374,16 @@ fn fmt_span(s: &Span) -> String {
fmt_day_short(s.first),
fmt_when(when_on(s, s.first), s.first)
),
(false, false) => format!(
"{} {} – {} {}",
fmt_day_short(s.first),
fmt_min(s.first, s.start_min),
fmt_day_short(s.last),
fmt_min(s.last, s.end_min)
),
(false, false) => {
let (day, min) = s.end_at();
format!(
"{} {} – {} {}",
fmt_day_short(s.first),
fmt_min(s.first, s.start_min),
fmt_day_short(day),
fmt_min(day, min)
)
}
}
}
@@ -364,6 +391,8 @@ fn fmt_span(s: &Span) -> String {
// The view
// ---------------------------------------------------------------------------
const TAB_IDS: [&str; 3] = ["cal-tab-month", "cal-tab-agenda", "cal-tab-invitations"];
/// Lines per day cell in the month grid, the last one shared with
/// "+N more".
const LINES: usize = 4;
@@ -504,6 +533,8 @@ pub fn CalendarMain(
let month = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
let month_seq = StoredValue::new(0u32);
let month_key = StoredValue::new(None::<(i32, u32)>);
// The new month's grid appears only after its fetch, without focus.
let refocus = StoredValue::new(false);
Effect::new(move |_| {
tick.track();
collections.track();
@@ -527,6 +558,22 @@ pub fn CalendarMain(
.await;
if month_seq.try_get_value() == Some(seq) {
let _ = month.try_set(Some(r.map_err(|e| e.to_string())));
if refocus.try_get_value() == Some(true) {
refocus.set_value(false);
request_animation_frame(|| {
let cell = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| {
d.query_selector(".cal-weeks .cal-cell:not(.cal-out)")
.ok()
.flatten()
})
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
if let Some(cell) = cell {
let _ = cell.focus();
}
});
}
}
});
});
@@ -614,6 +661,7 @@ pub fn CalendarMain(
.is_some();
if let (CalView::Month(y, m), true) = (view.get_untracked(), in_grid) {
ev.prevent_default();
refocus.set_value(true);
let (y, m) = add_months(y, m, step);
go(CalView::Month(y, m));
}
@@ -684,9 +732,36 @@ pub fn CalendarMain(
<div class="cal-toolbar">
{nav}
<h2 class="cal-title">{title}</h2>
<div class="cal-views" role="tablist">
<div class="cal-views" role="tablist" on:keydown=move |ev| {
let step = match ev.key().as_str() {
"ArrowRight" => 1,
"ArrowLeft" => -1,
_ => return,
};
ev.prevent_default();
let tabs = [this_month, CalView::Agenda, CalView::Invitations];
let at = tabs
.iter()
.position(|t| std::mem::discriminant(t) == std::mem::discriminant(&v))
.unwrap_or(0);
let to = (at as i32 + step).rem_euclid(3) as usize;
go(tabs[to]);
// The toolbar is rebuilt by the view change.
request_animation_frame(move || {
let tab = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(TAB_IDS[to]))
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
if let Some(tab) = tab {
let _ = tab.focus();
}
});
}>
<button
role="tab"
id=TAB_IDS[0]
aria-controls="cal-panel"
tabindex=if matches!(v, CalView::Month(..)) { "0" } else { "-1" }
class="cal-view-btn"
class:active=matches!(v, CalView::Month(..))
aria-selected=matches!(v, CalView::Month(..)).to_string()
@@ -696,6 +771,9 @@ pub fn CalendarMain(
</button>
<button
role="tab"
id=TAB_IDS[1]
aria-controls="cal-panel"
tabindex=if v == CalView::Agenda { "0" } else { "-1" }
class="cal-view-btn"
class:active=v == CalView::Agenda
aria-selected=(v == CalView::Agenda).to_string()
@@ -705,6 +783,9 @@ pub fn CalendarMain(
</button>
<button
role="tab"
id=TAB_IDS[2]
aria-controls="cal-panel"
tabindex=if v == CalView::Invitations { "0" } else { "-1" }
class="cal-view-btn"
class:active=v == CalView::Invitations
aria-selected=(v == CalView::Invitations).to_string()
@@ -722,11 +803,11 @@ pub fn CalendarMain(
let body = move || match view.get() {
CalView::Month(y, m) => view! {
<MonthView y=y m=m week_start=week_start.get() data=month shown=shown/>
<MonthView y=y m=m week_start=week_start.get() data=month on_retry=refresh shown=shown/>
}
.into_any(),
CalView::Agenda => view! {
<AgendaView data=agenda shown=shown/>
<AgendaView data=agenda on_retry=refresh shown=shown/>
}
.into_any(),
CalView::Invitations => view! {
@@ -738,7 +819,17 @@ pub fn CalendarMain(
view! {
<div class="cal">
{toolbar}
{body}
<div
id="cal-panel"
role="tabpanel"
aria-labelledby=move || TAB_IDS[match view.get() {
CalView::Month(..) => 0,
CalView::Agenda => 1,
CalView::Invitations => 2,
}]
>
{body}
</div>
</div>
{move || opened.get().map(|o| view! {
<EventDialog
@@ -759,10 +850,17 @@ fn truncated_note(list: &api::PimInstances) -> Option<impl IntoView + use<>> {
fn loading_or_error(
data: &Option<Result<api::PimInstances, String>>,
retry: Callback<()>,
) -> Result<api::PimInstances, AnyView> {
match data {
None => Err(view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any()),
Some(Err(e)) => Err(view! { <div class="card error-card">{e.clone()}</div> }.into_any()),
Some(Err(e)) => Err(view! {
<div class="card error-card">
<span>{e.clone()}</span>
<button class="btn" on:click=move |_| retry.run(())>{i18n::t(k::RETRY)}</button>
</div>
}
.into_any()),
Some(Ok(list)) => Ok(list.clone()),
}
}
@@ -796,6 +894,7 @@ fn MonthView(
m: u32,
week_start: u32,
data: RwSignal<Option<Result<api::PimInstances, String>>>,
on_retry: Callback<()>,
shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
) -> impl IntoView {
let start = grid_start(y, m, week_start);
@@ -828,7 +927,7 @@ fn MonthView(
view! {
<div class="cal-month">
<div class="cal-head">{head}</div>
{move || data.with(|d| match loading_or_error(d) {
{move || data.with(|d| match loading_or_error(d, on_retry) {
Err(v) => v,
Ok(list) => {
let items = shown(&list);
@@ -841,13 +940,19 @@ fn MonthView(
.map(|c| {
let day = week + c as i64;
let (_, _, dnum) = civil_from_days(day);
let dots: Vec<String> = items
.iter()
.filter(|s| s.span.covers(day))
.take(3)
.map(|s| s.color.clone())
.collect();
let label = fmt_day_long(day);
let on_day = items.iter().filter(|s| s.span.covers(day));
let n = on_day.clone().count();
let dots: Vec<String> =
on_day.take(3).map(|s| s.color.clone()).collect();
let label = match n {
0 => fmt_day_long(day),
1 => format!("{}, {}", fmt_day_long(day), i18n::t(k::PIM_EVENTS_ONE)),
n => format!(
"{}, {}",
fmt_day_long(day),
i18n::t_fmt(k::PIM_EVENTS_N, &n.to_string())
),
};
view! {
<button
class="cal-cell"
@@ -974,11 +1079,12 @@ const AGENDA_DAYS: i64 = 7;
#[component]
fn AgendaView(
data: RwSignal<Option<Result<api::PimInstances, String>>>,
on_retry: Callback<()>,
shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
) -> impl IntoView {
view! {
<div class="cal-agenda">
{move || data.with(|d| match loading_or_error(d) {
{move || data.with(|d| match loading_or_error(d, on_retry) {
Err(v) => v,
Ok(list) => {
let items = shown(&list);
@@ -1018,15 +1124,18 @@ fn AgendaView(
}
}
/// Answer an invitation, then refresh the lists.
/// Answer an invitation, then refresh the lists. `busy` stays set on success;
/// `done` ends it once the refresh has landed.
fn reply(
toast: ToastMsg,
busy: RwSignal<bool>,
collection_id: i64,
name: String,
recurrence_id: Option<String>,
partstat: &'static str,
done: Callback<()>,
) {
busy.set(true);
spawn_local(async move {
let body = PimReply {
collection_id,
@@ -1041,7 +1150,10 @@ fn reply(
// The dialog or view may be gone by now.
let _ = done.try_run(());
}
Err(e) => show_error(toast, e.to_string()),
Err(e) => {
let _ = busy.try_set(false);
show_error(toast, e.to_string());
}
}
});
}
@@ -1049,6 +1161,7 @@ fn reply(
/// Accept, maybe, decline. `current` marks the answer already given.
fn reply_buttons(
current: Option<String>,
busy: RwSignal<bool>,
on: impl Fn(&'static str) + Clone + 'static,
) -> impl IntoView {
[
@@ -1064,6 +1177,7 @@ fn reply_buttons(
class="btn btn-sm"
class:btn-primary=active
aria-pressed=active.to_string()
disabled=move || busy.get()
on:click=move |_| on(ps)
>
{i18n::t(label)}
@@ -1085,7 +1199,13 @@ fn InvitationsView(
<div class="cal-invitations">
{move || match data.get() {
None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(),
Some(Err(e)) => view! {
<div class="card error-card">
<span>{e}</span>
<button class="btn" on:click=move |_| on_replied.run(())>{i18n::t(k::RETRY)}</button>
</div>
}
.into_any(),
Some(Ok(list)) if list.is_empty() => view! {
<div class="empty-state">
{icon_svg(IconName::Calendar, "empty-glyph")}
@@ -1127,6 +1247,7 @@ fn InvitationsView(
let (id, name, rid) =
(inv.collection_id, inv.name.clone(), inv.recurrence_id.clone());
let open = o.clone();
let busy = RwSignal::new(false);
view! {
<div class="cal-inv">
<button class="cal-inv-main" on:click=move |_| open.open()>
@@ -1141,8 +1262,8 @@ fn InvitationsView(
})}
</button>
<div class="cal-inv-actions">
{reply_buttons(None, move |ps| {
reply(toast, id, name.clone(), rid.clone(), ps, on_replied)
{reply_buttons(None, busy, move |ps| {
reply(toast, busy, id, name.clone(), rid.clone(), ps, on_replied)
})}
</div>
</div>
@@ -1251,10 +1372,14 @@ fn EventDialog(
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let detail = RwSignal::new(Option::<Result<PimEventDetail, String>>::None);
let busy = RwSignal::new(false);
let load_seq = StoredValue::new(0u32);
let load = {
let t = target.clone();
move || {
let t = t.clone();
let seq = load_seq.get_value() + 1;
load_seq.set_value(seq);
spawn_local(async move {
let r = api::pim_object(
t.collection_id,
@@ -1268,7 +1393,10 @@ fn EventDialog(
Ok(PimObjectDetail::Contact(_)) => Err(i18n::t(k::PIM_NOT_EVENT).to_string()),
Err(e) => Err(e.to_string()),
};
let _ = detail.try_set(Some(r));
if load_seq.try_get_value() == Some(seq) {
let _ = detail.try_set(Some(r));
let _ = busy.try_set(false);
}
});
}
};
@@ -1307,6 +1435,7 @@ fn EventDialog(
let load = load.clone();
reply(
toast,
busy,
id,
name.clone(),
rid,
@@ -1322,7 +1451,7 @@ fn EventDialog(
.clone()
.filter(|u| u.starts_with("https://") || u.starts_with("http://"));
view! {
<h2 class="modal-title cal-detail-title" class:cal-cancelled=d.status.as_deref() == Some("CANCELLED")>
<h2 id="cal-detail-title" class="modal-title cal-detail-title" class:cal-cancelled=d.status.as_deref() == Some("CANCELLED")>
{title}
</h2>
{(d.status.as_deref() == Some("CANCELLED")).then(|| view! {
@@ -1410,7 +1539,7 @@ fn EventDialog(
</label>
</div>
})}
<div class="cal-inv-actions">{reply_buttons(mine, answer)}</div>
<div class="cal-inv-actions">{reply_buttons(mine, busy, answer)}</div>
</section>
})}
}
@@ -1418,7 +1547,7 @@ fn EventDialog(
}
};
view! {
<Modal class="pim-modal cal-detail" on_close=on_close>
<Modal class="pim-modal cal-detail" labelled_by="cal-detail-title" on_close=on_close>
{body}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>{i18n::t(k::CLOSE)}</button>
@@ -1620,6 +1749,61 @@ mod tests {
);
}
#[test]
fn links_need_a_host_and_keep_balanced_brackets() {
assert_eq!(
split_links("a https:// b"),
[("a https:// b".to_string(), false)]
);
assert_eq!(
split_links("(https://en.wikipedia.org/wiki/Foo_(bar))"),
[
("(".to_string(), false),
("https://en.wikipedia.org/wiki/Foo_(bar)".to_string(), true),
(")".to_string(), false),
]
);
}
#[test]
fn more_counts_the_hidden_items_of_a_column() {
let week = grid_start(2026, 10, 1);
let d = |c: i64| week + c;
let five: Vec<Span> = (0..5).map(|n| timed(d(1), 60 * n)).collect();
let l = layout_week(&five, week, 3);
assert_eq!(l.more, [0, 3, 0, 0, 0, 0, 0]);
}
#[test]
fn a_lone_item_on_a_low_line_gets_no_more() {
let week = grid_start(2026, 10, 1);
let d = |c: i64| week + c;
let spans = [all_day(d(0), d(4)), timed(d(6), 600)];
let l = layout_week(&spans, week, 3);
assert_eq!(l.more, [0; 7]);
}
#[test]
fn a_bar_pushed_below_the_lines_is_counted_where_it_hides() {
let week = grid_start(2026, 10, 1);
let d = |c: i64| week + c;
// Three bars fill the lines of days 0 to 4, so the fourth lands on line 3.
let mut spans = vec![all_day(d(0), d(4)); 3];
spans.push(all_day(d(4), d(6)));
let l = layout_week(&spans, week, 3);
assert_eq!(l.more, [0, 0, 0, 0, 2, 1, 1]);
}
#[test]
fn midnight_end_is_the_next_day() {
let mut s = timed(5, 600);
s.last = 6;
s.end_min = 1440;
assert_eq!(s.end_at(), (7, 0));
s.end_min = 540;
assert_eq!(s.end_at(), (6, 540));
}
#[test]
fn opened_round_trips() {
let o = Opened {
Mweb/src/views/contacts.rs
@@ -6,7 +6,9 @@ use std::collections::HashSet;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, PimCollectionInfo, PimContact, PimContactDetail, PimObjectDetail};
use crate::api::{
self, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail, PimObjectDetail,
};
use crate::components::icon::icon_svg;
use crate::i18n::{self, k};
use crate::icons::IconName;
@@ -45,18 +47,25 @@ pub fn vcard_date(s: &str) -> Option<(Option<i32>, u32, u32)> {
_ => return None,
};
let (m, d) = (md[..2].parse().ok()?, md[2..].parse().ok()?);
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some((year, m, d))
// No year: February 29 is allowed.
let leap = year.is_none_or(|y| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0));
let days = match m {
2 if leap => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
};
((1..=12).contains(&m) && (1..=days).contains(&d)).then_some((year, m, d))
}
fn fmt_vcard_date(s: &str) -> String {
let Some((year, m, d)) = vcard_date(s) else {
return s.to_string();
};
// A leap year, so February 29 exists when the year is unknown.
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(
js_sys::Date::utc(f64::from(year.unwrap_or(2000)), f64::from(m - 1))
+ f64::from(d - 1) * 86_400_000.0,
));
// `Date.UTC` maps years below 100 to 19xx, so set the year separately. A
// leap year, so February 29 exists when the year is unknown.
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0));
date.set_utc_full_year_with_month_date(year.unwrap_or(2000) as u32, m as i32 - 1, d as i32);
match year {
Some(_) => intl(
&date,
@@ -141,15 +150,33 @@ pub fn ContactsMain(
let contacts = RwSignal::new(Option::<Result<Vec<PimContact>, String>>::None);
let seq = StoredValue::new(0u32);
Effect::new(move |_| {
collections.track();
let q = query.get();
let ids = collections.with(|c| {
hidden.with(|h| {
c.as_ref().map(|c| {
c.iter()
.filter(|c| c.kind == PimCollectionKind::Addressbook && !h.contains(&c.id))
.map(|c| c.id)
.collect::<Vec<_>>()
})
})
});
let Some(ids) = ids else { return };
let n = seq.get_value() + 1;
seq.set_value(n);
spawn_local(async move {
let r = api::pim_contacts(&q).await;
if seq.try_get_value() == Some(n) {
let _ = contacts.try_set(Some(r.map_err(|e| e.to_string())));
let r = api::pim_contacts(&q, &ids).await;
if seq.try_get_value() != Some(n) {
return;
}
// A hidden book's contact stays open when it comes from a link.
if let (Ok(list), Some((id, name))) = (&r, opened.get_untracked())
&& !hidden.with_untracked(|h| h.contains(&id))
&& !list.iter().any(|c| c.collection_id == id && c.name == name)
{
router::close_open();
}
let _ = contacts.try_set(Some(r.map_err(|e| e.to_string())));
});
});
@@ -316,7 +343,10 @@ pub fn ContactsMain(
</div>
</div>
<dl class="cal-facts">
{labeled(k::PIM_EMAIL, c.emails.clone(), |v| Some(format!("mailto:{v}")))}
{labeled(k::PIM_EMAIL, c.emails.clone(), |v| {
let addr = String::from(js_sys::encode_uri_component(v)).replace("%40", "@");
Some(format!("mailto:{addr}"))
})}
{labeled(k::PIM_PHONE, c.phones.clone(), |v| {
Some(format!("tel:{}", v.chars().filter(|c| !c.is_whitespace()).collect::<String>()))
})}
@@ -385,6 +415,10 @@ mod tests {
);
assert_eq!(vcard_date("sometime"), None);
assert_eq!(vcard_date("1980-13-01"), None);
assert_eq!(vcard_date("1980-04-31"), None);
assert_eq!(vcard_date("1981-02-29"), None);
assert_eq!(vcard_date("1900-02-29"), None);
assert_eq!(vcard_date("2000-02-29"), Some((Some(2000), 2, 29)));
// Multi-byte values must not panic on byte slicing.
assert_eq!(vcard_date("1980年3"), None);
assert_eq!(vcard_date("--03é1"), None);
Mweb/src/views/pim.rs
@@ -6,6 +6,7 @@ use std::collections::HashSet;
use api_types::{CreatePimCollection, CreatePimLink, UpdatePimCollection};
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{
@@ -434,7 +435,8 @@ fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
<div class="pim-colors" role="radiogroup" aria-label=i18n::tr(k::PIM_COLOR)>
{PALETTE
.iter()
.map(|&c| {
.enumerate()
.map(|(i, &c)| {
view! {
<button
type="button"
@@ -442,6 +444,32 @@ fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
class:selected=move || color.with(|v| v.as_deref() == Some(c))
role="radio"
aria-checked=move || color.with(|v| v.as_deref() == Some(c)).to_string()
tabindex=move || {
let tab = color.with(|v| match v.as_deref() {
Some(v) if PALETTE.contains(&v) => v == c,
_ => i == 0,
});
if tab { "0" } else { "-1" }
}
on:keydown=move |ev| {
let step = match ev.key().as_str() {
"ArrowRight" | "ArrowDown" => 1,
"ArrowLeft" | "ArrowUp" => -1,
_ => return,
};
ev.prevent_default();
let to = (i as i32 + step).rem_euclid(PALETTE.len() as i32);
color.set(Some(PALETTE[to as usize].to_string()));
let next = ev
.current_target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.and_then(|t| t.parent_element())
.and_then(|g| g.children().item(to as u32))
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
if let Some(next) = next {
let _ = next.focus();
}
}
title=c
style=format!("--swatch: {c}")
on:click=move |_| color.set(Some(c.to_string()))
@@ -558,13 +586,14 @@ fn NewCollectionDialog(
}
/// A read-only URL with a copy button.
fn url_row(url: String, toast: ToastMsg) -> impl IntoView {
fn url_row(label: &'static str, url: String, toast: ToastMsg) -> impl IntoView {
let copy = url.clone();
view! {
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
aria-label=i18n::t(label)
value=url
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
@@ -599,18 +628,25 @@ fn CollectionDialog(
)
});
let generated_note = info.generated.then(|| i18n::t(k::PIM_GENERATED_HINT));
let head = RwSignal::new(info.clone());
let saved = Callback::new(move |(name, color): (String, Option<String>)| {
head.update(|h| {
h.name = name;
h.color = color;
})
});
view! {
<Modal class="pim-modal" on_close=on_close>
<h2 class="modal-title pim-modal-title">
<span class="pim-swatch" style=format!("--swatch: {}", safe_color(info.color.as_deref()))></span>
<span>{display_name(&info)}</span>
<span class="pim-swatch" style=move || format!("--swatch: {}", head.with(|h| safe_color(h.color.as_deref())))></span>
<span>{move || head.with(display_name)}</span>
</h2>
{lent_note.map(|n| view! { <p class="muted pim-note">{n}</p> })}
{generated_note.map(|n| view! { <p class="muted pim-note">{n}</p> })}
{own.then(|| view! { <GeneralSection info=info.clone() on_changed=on_changed/> })}
{own.then(|| view! { <GeneralSection info=info.clone() on_changed=on_changed on_saved=saved/> })}
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_ADDRESS)}</h3>
{url_row(url, toast)}
{url_row(k::PIM_ADDRESS, url, toast)}
</section>
{own.then(|| view! { <SharingSection info=info.clone()/> })}
{own.then(|| view! { <LinksSection info=info.clone()/> })}
@@ -627,7 +663,11 @@ fn CollectionDialog(
}
#[component]
fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView {
fn GeneralSection(
info: PimCollectionInfo,
on_changed: Callback<()>,
on_saved: Callback<(String, Option<String>)>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let calendar = info.kind == PimCollectionKind::Calendar;
@@ -642,6 +682,7 @@ fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl Int
return;
}
set_busy.set(true);
let saved = (name.get(), color.get().filter(|c| !c.is_empty()));
let body = UpdatePimCollection {
name: Some(name.get()),
color: Some(color.get().unwrap_or_default()),
@@ -652,6 +693,7 @@ fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl Int
match api::pim_update_collection(id, body).await {
Ok(_) => {
show(toast, i18n::t(k::SAVED));
let _ = on_saved.try_run(saved);
let _ = on_changed.try_run(());
}
Err(e) => show_error(toast, i18n::t_fmt(k::PIM_SAVE_ERR, &e.to_string())),
@@ -718,15 +760,25 @@ fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
let (user, set_user) = signal(String::new());
let (mode, set_mode) = signal(PimShareMode::Ro);
// Borrowers drop out of the candidates, so both lists reload together.
let load_seq = StoredValue::new(0u32);
let load = move || {
let seq = load_seq.get_value() + 1;
load_seq.set_value(seq);
spawn_local(async move {
match api::pim_shares(id).await {
let latest = move || load_seq.try_get_value() == Some(seq);
let shares = api::pim_shares(id).await;
if !latest() {
return;
}
match shares {
Ok(v) => {
let _ = set_shares.try_set(Some(v));
}
Err(e) => show_error(toast, e.to_string()),
}
if let Ok(v) = api::pim_share_candidates(id).await {
if let Ok(v) = api::pim_share_candidates(id).await
&& latest()
{
let _ = set_candidates.try_set(v);
}
});
@@ -1373,13 +1425,13 @@ fn ConnectDialog(
<h2 class="modal-title">{i18n::tr(k::PIM_CONNECT_TITLE)}</h2>
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_SERVER_ADDRESS)}</h3>
{url_row(server_url("/"), toast)}
{url_row(k::PIM_SERVER_ADDRESS, server_url("/"), toast)}
<p class="muted pim-hint">{i18n::tr(k::PIM_SERVER_ADDRESS_HINT)}</p>
{url_row(server_url(&format!("{}/", api_types::PIM)), toast)}
{url_row(k::PIM_ADDRESS, server_url(&format!("{}/", api_types::PIM)), toast)}
</section>
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_USER)}</h3>
{url_row(user, toast)}
{url_row(k::PIM_USER, user, toast)}
<p class="muted pim-hint">{i18n::tr(k::PIM_PASSWORD_HINT)}</p>
<div class="pim-section-actions pim-left">
<button type="button" class="btn btn-sm" on:click=app_passwords>