CalDAV/CardDAV review fixes, round 6

- An attendee left out of a THISANDFUTURE range loses that range: their
  series ends before it, or is cancelled when the range starts at the
  first instance
- All rules of a series share one occurrence budget
- A reply's REQUEST-STATUS code is reduced to digits and dots before it
  becomes SCHEDULE-STATUS
- Attendees may not change an instance through a new or dropped
  override; deleting an instance via EXDATE, dropping status-only
  overrides and overrides without an end stay allowed
- An open-ended time-range on an endless rule matches without expanding
- vCard 4.0 output keeps PHOTO data URIs intact; expand keeps tasks
  without a start, in UTC; a vCard without END:VCARD is refused
- Import gives a second master of a UID its own object, and overrides
  follow the master that has their instance; busy feeds keep free
  THISANDFUTURE overrides
- Sync tokens of a cut initial sync survive writes between pages
- Principal search shows the caller's own home; the collection limit is
  checked under LOCK and leaves out the inbox; HEAD sends the length;
  slugs never take the lent form
- Web: the reply scope defaults from the series' answer and stays put;
  stable tab keys with Home/End; Today selects today; contact search
  keeps its query on Back; no panic after a closed share panel
- Tests for each change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitaacdd4ff36abed9b298d722940974f6bd2e5518e
Parent152a4f7
25 files changed, 898 insertions(+), 143 deletions(-)
▾MREADME.md
@@ -500,8 +500,8 @@ for the old one.
- A repeating event returned as single instances can have at most 10,000
in one request, and one request returns at most 20,000 such instances.
Past that, apps get a shortened answer.
- A repeating rule is followed for at most 1,000,000 occurrences per
request. Only extreme rules reach this, such as every minute for years.
- The rules of one repeating event are followed for at most 1,000,000
occurrences together per request. Only extreme rules reach this, such as every minute for years.
Such an event counts as matching every time range.
- A room checks a repeating meeting without end for the next two years.
Anything else is checked to its end, at most ten years ahead. Later
▾Mapi-types/src/lib.rs
@@ -727,6 +727,9 @@ pub struct PimEventDetail {
pub rrule: Option<String>,
pub organizer: Option<PimPerson>,
pub attendees: Vec<PimAttendee>,
/// The owner's PARTSTAT on the series' master, when they attend it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_partstat: Option<String>,
/// The signed-in user may change the object with a client.
pub can_edit: bool,
/// The calendar owner is an attendee and the signed-in user may answer
▾Mpimdav/README.md
@@ -153,7 +153,8 @@ rule with BYMONTH, that is `1TU` to `5TU`. Otherwise it is `1TU` to `53TU`.
### Cost limits
- One rule generates at most 1,000,000 occurrences per expansion. Past
- The rules of one series generate at most 1,000,000 occurrences together
per expansion. Past
that, `Expansion::truncated` is set and later instances are missing.
Iteration always starts at DTSTART, so a `FREQ=MINUTELY` rule from years
ago reaches the cap.
▾Mpimdav/src/bundle.rs
@@ -6,12 +6,16 @@
use std::collections::{HashMap, HashSet};
use calcard::icalendar::{ICalendar, ICalendarProperty};
use chrono::TimeDelta;
use sha2::{Digest, Sha256};
use crate::expand::{expand, stamp};
use crate::text::{
escape_text, fold, logical_lines, name, param, param_parts, unescape_text, unfold, value,
value_start,
};
use crate::zone::{Zone, Zones};
const PRODID: &str = "PRODID:-//dovenest//pimdav//EN";
@@ -123,7 +127,8 @@ pub fn calendar_meta(text: &str) -> (Option<String>, Option<String>) {
/// VERSION, PRODID and CALSCALE of the file and drops the other calendar
/// properties, METHOD among them. A component without UID gets
/// `new_uid(its text)`, and so does a component type that reuses the UID of
/// another type: one object holds one type only. `None` when the objects
/// another type, or a second master of a UID: one object holds one type and
/// one master. `None` when the objects
/// would pass `max_bytes` together: each one repeats the zones it names.
pub fn split_calendar(
text: &str,
@@ -137,8 +142,55 @@ pub fn split_calendar(
// By the UID in the file and the component type.
let mut by_uid: HashMap<(String, String), usize> = HashMap::new();
let mut file_uids: HashSet<String> = HashSet::new();
// The masters of each file UID and component type, as group indexes. A
// second master starts an object of its own.
let mut masters: HashMap<(String, String), Vec<usize>> = HashMap::new();
// Overrides wait until every master is known: each joins the master that
// has its instance.
let mut overrides: Vec<(Block, HashSet<String>)> = Vec::new();
// A file that holds the same component twice keeps one copy.
let mut seen: HashSet<String> = HashSet::new();
let mut add = |groups: &mut Vec<(String, String, HashSet<String>)>,
c: &Block,
tzids: HashSet<String>,
group: Option<usize>| {
let file_uid = c.prop("UID").filter(|u| !u.trim().is_empty());
// An override without its master joins the others of its UID.
let group = group.or_else(|| match (c.prop("RECURRENCE-ID"), &file_uid) {
(Some(_), Some(u)) => by_uid.get(&(u.clone(), c.name.clone())).copied(),
_ => None,
});
let uid = match (&file_uid, group) {
(_, Some(i)) => groups[i].0.clone(),
(Some(u), None) if !file_uids.contains(u) => u.clone(),
_ => new_uid(&lines_text(&c.lines)),
};
let text = match file_uid.as_ref() == Some(&uid) {
true => lines_text(&c.lines),
false => {
let rest = without_own(&c.lines, "UID");
let mut text = String::new();
push_lines(&mut text, &rest[..1]);
text.push_str(&format!("UID:{uid}\r\n"));
push_lines(&mut text, &rest[1..]);
text
}
};
match group {
Some(i) => {
groups[i].1.push_str(&text);
groups[i].2.extend(tzids);
i
}
None => {
let key = file_uid.unwrap_or_else(|| uid.clone());
file_uids.insert(key.clone());
by_uid.entry((key, c.name.clone())).or_insert(groups.len());
groups.push((uid, text, tzids));
groups.len() - 1
}
}
};
for cal in top_blocks(text, "VCALENDAR") {
let (props, children) = split_level(inner(&cal.lines));
header.get_or_insert_with(|| {
@@ -160,43 +212,35 @@ pub fn split_calendar(
.iter()
.filter_map(|l| param(&unfold(l), "TZID"))
.collect();
let file_uid = c.prop("UID").filter(|u| !u.trim().is_empty());
let group = file_uid
.as_ref()
.and_then(|u| by_uid.get(&(u.clone(), c.name.clone())).copied());
let uid = match (&file_uid, group) {
(_, Some(i)) => groups[i].0.clone(),
(Some(u), None) if !file_uids.contains(u) => u.clone(),
_ => new_uid(&lines_text(&c.lines)),
};
let text = match file_uid.as_ref() == Some(&uid) {
true => lines_text(&c.lines),
false => {
let rest = without_own(&c.lines, "UID");
let mut text = String::new();
push_lines(&mut text, &rest[..1]);
text.push_str(&format!("UID:{uid}\r\n"));
push_lines(&mut text, &rest[1..]);
text
}
};
if !seen.insert(text.clone()) {
if !seen.insert(lines_text(&c.lines)) {
continue;
}
match group {
Some(i) => {
groups[i].1.push_str(&text);
groups[i].2.extend(tzids);
}
None => {
let key = file_uid.unwrap_or_else(|| uid.clone());
file_uids.insert(key.clone());
by_uid.insert((key, c.name.clone()), groups.len());
groups.push((uid, text, tzids));
let file_uid = c.prop("UID").filter(|u| !u.trim().is_empty());
match (c.prop("RECURRENCE-ID").is_some(), file_uid) {
(true, Some(_)) => overrides.push((c, tzids)),
(_, file_uid) => {
let at = add(&mut groups, &c, tzids, None);
if let Some(u) = file_uid {
masters.entry((u, c.name.clone())).or_default().push(at);
}
}
}
}
}
let master_texts: Vec<String> = groups.iter().map(|g| g.1.clone()).collect();
for (c, tzids) in overrides {
let key = (c.prop("UID").unwrap_or_default(), c.name.clone());
let group = match masters.get(&key).map(Vec::as_slice) {
Some([only]) => Some(*only),
Some(list) => list
.iter()
.copied()
.find(|&i| has_instance(&master_texts[i], &c, &zones))
.or(list.first().copied()),
None => None,
};
add(&mut groups, &c, tzids, group);
}
let mut head = String::from("BEGIN:VCALENDAR\r\n");
let header = header.unwrap_or_default();
if !header.iter().any(|l| name(l) == "VERSION") {
@@ -391,10 +435,12 @@ fn blocks_time(e: &Block) -> bool {
/// The events of one object as busy time. Events that block no time are
/// left out; a left-out override becomes an EXDATE of its master, so its
/// instance stays free. The UID becomes a hash of it, because UIDs often
/// instance stays free. A free THISANDFUTURE override stays, so it frees the
/// later instances too. The UID becomes a hash of it, because UIDs often
/// hold host names or mail addresses.
fn push_busy(out: &mut String, events: &[&Block]) {
let (kept, free): (Vec<&&Block>, Vec<&&Block>) = events.iter().partition(|e| blocks_time(e));
let (kept, free): (Vec<&&Block>, Vec<&&Block>) =
events.iter().partition(|e| blocks_time(e) || ranged(e));
let mut exdates: Vec<String> = free
.iter()
.filter_map(|e| {
@@ -431,6 +477,14 @@ fn push_busy(out: &mut String, events: &[&Block]) {
}
}
/// Whether `e` overrides an instance and all later ones.
fn ranged(e: &Block) -> bool {
split_level(inner(&e.lines)).0.into_iter().any(|l| {
name(l) == "RECURRENCE-ID"
&& param(&unfold(l), "RANGE").is_some_and(|r| r.eq_ignore_ascii_case("THISANDFUTURE"))
})
}
/// A RECURRENCE-ID line as the EXDATE of that instance, without RANGE.
fn exdate(rid: &str) -> String {
let line = unfold(rid);
@@ -442,3 +496,39 @@ fn exdate(rid: &str) -> String {
.collect();
format!("EXDATE{params}:{}\r\n", &line[start..])
}
/// Whether the series in `master` has an instance at the RECURRENCE-ID of
/// `over`.
fn has_instance(master: &str, over: &Block, zones: &HashMap<String, String>) -> bool {
let cal = |body: &str| {
let mut text = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n");
text.extend(zones.values().map(String::as_str));
text.push_str(body);
text.push_str("END:VCALENDAR\r\n");
ICalendar::parse(&text).ok()
};
let (Some(series), Some(over_cal)) = (cal(master), cal(&lines_text(&over.lines))) else {
return false;
};
let zone_set = Zones::new(&over_cal, Zone::Utc);
let Some(rid) = over_cal
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::RecurrenceId))
.and_then(|e| {
stamp(
&zone_set,
e.values.first()?.as_partial_date_time()?,
e.tz_id(),
)
})
.map(|s| s.utc())
else {
return false;
};
let window = rid - TimeDelta::days(1)..rid + TimeDelta::days(1);
expand(&series, window, Zone::Utc)
.instances
.iter()
.any(|i| i.recurrence_id == Some(rid))
}
▾Mpimdav/src/expand.rs
@@ -14,7 +14,8 @@ use chrono::{DateTime, NaiveDateTime, NaiveTime, TimeDelta, TimeZone, Timelike,
use crate::zone::{Zone, Zones, add, add_local};
/// Occurrences one rule may generate before expansion gives up.
/// Occurrences the rules of one series may generate together before
/// expansion gives up.
// 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;
@@ -34,7 +35,7 @@ pub struct Instance {
pub struct Expansion {
/// Sorted by start.
pub instances: Vec<Instance>,
/// A rule reached `MAX_OCCURRENCES`, so later instances are missing.
/// A series reached `MAX_OCCURRENCES`, so later instances are missing.
pub truncated: bool,
}
@@ -349,12 +350,13 @@ fn expand_group(
let (from, to) = (add(window.start, -slack), add(window.end, slack));
let (from_local, to_local) = (mz.to_local(from), mz.to_local(to));
let mut budget = MAX_OCCURRENCES;
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, from_local, to_local, out) {
for local in occurrences_capped(rule, &m, from_local, to_local, &mut budget, out) {
excluded.insert(m.member_key(local, mz.to_utc(local)));
}
}
@@ -362,7 +364,7 @@ fn expand_group(
// 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, from_local, to_local, out) {
for local in occurrences_capped(rule, &m, from_local, to_local, &mut budget, out) {
let utc = mz.to_utc(local);
if utc >= from {
set.entry(m.member_key(local, utc)).or_insert(Member {
@@ -467,6 +469,7 @@ fn occurrences_capped(
m: &Timing,
from_local: NaiveDateTime,
to_local: NaiveDateTime,
budget: &mut usize,
out: &mut Expansion,
) -> Vec<NaiveDateTime> {
let until = match &rule.until {
@@ -494,12 +497,13 @@ fn occurrences_capped(
}
}
let mut list: Vec<_> = occurrences(rule, first, until, to_local)
.take(MAX_OCCURRENCES + 1)
.take(*budget + 1)
.collect();
if list.len() > MAX_OCCURRENCES {
if list.len() > *budget {
list.pop();
out.truncated = true;
}
*budget -= list.len();
// DTSTART is the first of COUNT (RFC 5545, 3.3.10), also when the rule
// skips it.
if let Some(n) = rule.count.filter(|_| until.is_none()).map(|n| n as usize)
▾Mpimdav/src/filter.rs
@@ -18,6 +18,7 @@ use xmltree::Element;
use crate::expand::{Expansion, expand, stamp};
use crate::freebusy::periods;
use crate::itip::endless;
use crate::report::Refused;
use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text};
use crate::zone::{Zone, Zones, add};
@@ -402,6 +403,9 @@ impl Ctx<'_> {
| ICalendarComponentType::VJournal
if has(ICalendarProperty::Dtstart) =>
{
if r.end == DateTime::<Utc>::MAX_UTC && endless(&self.cal.components[i]) {
return true;
}
let todo = c.component_type == ICalendarComponentType::VTodo;
let (due, duration) = (
has(ICalendarProperty::Due),
@@ -492,6 +496,9 @@ impl Ctx<'_> {
else {
return false;
};
if r.end == DateTime::<Utc>::MAX_UTC && endless(&self.cal.components[parent]) {
return true;
}
let from_end = trigger.parameter(&ICalendarParameterName::Related)
== Some(&ICalendarParameterValue::Related(ICalendarRelated::End));
let exp =
▾Mpimdav/src/itip.rs
@@ -158,12 +158,7 @@ pub fn stamp_sender(cal: &mut ICalendar, owner: Is, sender: Option<&str>) {
/// years ahead.
pub fn answer_horizon(copy: &ICalendar) -> TimeDelta {
let endless = copy.components.iter().any(|c| {
is_scheduled(c)
&& !c.has_property(&ICalendarProperty::RecurrenceId)
&& c.properties(&ICalendarProperty::Rrule).any(|e| {
matches!(e.values.first(), Some(ICalendarValue::RecurrenceRule(r))
if r.count.is_none() && r.until.is_none())
})
is_scheduled(c) && !c.has_property(&ICalendarProperty::RecurrenceId) && endless(c)
});
TimeDelta::days(if endless { 731 } else { 3653 })
}
@@ -347,14 +342,14 @@ pub fn messages(
let after = new.as_ref().and_then(|n| Some((n, n.view(a)?)));
let (method, quiet, comps, src) = match (before, after) {
(Some((src, b)), None) => {
// A component that still lists them has SCHEDULE-AGENT=CLIENT
// A component that lists them with SCHEDULE-AGENT=CLIENT
// now: the client tells them.
let lost: Vec<Node> = b
.into_iter()
.filter(|c| {
!new.as_ref()
.and_then(|n| n.find(src.key(&c.c)))
.is_some_and(|nc| lists(&nc.c, a))
.is_some_and(|nc| client_scheduled(&nc.c, a))
})
.collect();
if lost.is_empty() {
@@ -448,14 +443,9 @@ pub fn attend(
let kept_exdates = old
.times(&oc.c, &ICalendarProperty::Exdate)
.is_subset(&next.times(&c.c, &ICalendarProperty::Exdate));
let organizer = |c: &ICalendarComponent| {
c.property(&ICalendarProperty::Organizer)
.and_then(address)
.map(str::to_ascii_lowercase)
};
if !same_times
|| !kept_exdates
|| organizer(&oc.c) != organizer(&c.c)
|| organizer_of(&oc.c) != organizer_of(&c.c)
|| addresses(&oc.c) != addresses(&c.c)
{
return Err(Refused::AttendeeChange);
@@ -463,16 +453,35 @@ pub fn attend(
}
// An instance the attendee overrides, to set its own status.
None => {
let start =
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
let shifted = key.map(|k| old.moved(k));
if key.is_none() || master.is_none() || start != shifted {
if !key.is_some_and(|k| master.is_some() && old.plain(&next, &c.c, k)) {
return Err(Refused::AttendeeChange);
}
}
}
}
// Dropping an override is fine where it held no change of the
// organizer's, where the attendee deletes that instance with an EXDATE,
// and in a copy of single instances.
let exdated: HashSet<i64> = next
.master()
.into_iter()
.flat_map(|m| m.c.properties(&ICalendarProperty::Exdate))
.flat_map(|e| {
e.values
.iter()
.filter_map(|v| next.at(v.as_partial_date_time()?, e.tz_id()))
})
.collect();
for oc in old.comps() {
let key = old.key(&oc.c);
let dropped = |k: i64| {
master.is_none()
|| !is_range(&oc.c) && (exdated.contains(&k) || old.plain(&old, &oc.c, k))
};
if next.find(key).is_none() && !key.is_some_and(dropped) {
return Err(Refused::AttendeeChange);
}
}
// The state of the others and of the organizer is the server's. A new
// override takes it from the THISANDFUTURE override that moves it.
@@ -774,10 +783,17 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
else {
continue;
};
// The attendee writes these; only digits and dots are a status code.
let codes: Vec<String> =
rc.c.properties(&ICalendarProperty::RequestStatus)
.filter_map(|e| e.values.first()?.as_text())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
.map(|s| {
let code = s.split(';').next().unwrap_or(s);
code.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect::<String>()
})
.filter(|c| !c.is_empty())
.collect();
let status = match codes.is_empty() {
true => "2.0".to_string(),
@@ -1242,7 +1258,8 @@ impl Obj {
/// What attendee `a` gets to see (RFC 6638, 3.2.6): the master with the
/// overrides it is in, and EXDATEs for those it is not in; or just the
/// overrides it is in.
/// overrides it is in. A THISANDFUTURE override it is not in ends its
/// series there.
fn view(&self, a: &str) -> Option<Vec<Node>> {
let invited = |c: &ICalendarComponent| {
attendees(c)
@@ -1252,21 +1269,42 @@ impl Obj {
let master = self.master().filter(|m| invited(&m.c));
if let Some(m) = master {
let mut m = m.clone();
let mut end: Option<i64> = None;
for o in self
.comps()
.filter(|c| c.c.has_property(&ICalendarProperty::RecurrenceId))
{
if !invited(&o.c)
&& let Some(rid) = o.c.property(&ICalendarProperty::RecurrenceId)
{
m.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Exdate,
params: without(rid.params.clone(), &ICalendarParameterName::Range),
values: rid.values.clone(),
});
let Some(rid) = o.c.property(&ICalendarProperty::RecurrenceId) else {
continue;
};
if invited(&o.c) {
continue;
}
if is_range(&o.c) {
// ponytail: a later range that invites them again is lost too.
if let Some(t) = self.instant(rid) {
end = Some(end.map_or(t, |e| e.min(t)));
}
continue;
}
m.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Exdate,
params: without(rid.params.clone(), &ICalendarParameterName::Range),
values: rid.values.clone(),
});
}
let first =
m.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| self.instant(e));
match end {
// The range starts with the first instance: none is left.
Some(t) if first.is_some_and(|f| t <= f) => {}
Some(t) => {
self.end_series(&mut m.c, t);
out.push(m);
}
None => out.push(m),
}
out.push(m);
}
out.extend(
self.comps()
@@ -1281,6 +1319,102 @@ impl Obj {
(!out.is_empty()).then_some(out)
}
/// Whether override `c`, read through `obj`, is instance `key` of this
/// series unchanged: its start, length and people, no rules of its own.
fn plain(&self, obj: &Obj, c: &ICalendarComponent, key: i64) -> bool {
let Some(base) = self.future(key).map(|(n, _)| n).or(self.master()) else {
return false;
};
let start = c
.property(&ICalendarProperty::Dtstart)
.and_then(|e| obj.instant(e));
start == Some(self.moved(key))
&& [
ICalendarProperty::Rrule,
ICalendarProperty::Rdate,
ICalendarProperty::Exrule,
]
.iter()
.all(|p| !c.has_property(p))
&& obj.length(c) == self.length(&base.c)
&& c.property(&ICalendarProperty::Status).map(|e| &e.values)
== base
.c
.property(&ICalendarProperty::Status)
.map(|e| &e.values)
&& organizer_of(c) == organizer_of(&base.c)
&& addresses(c) == addresses(&base.c)
}
/// Seconds from DTSTART to DTEND or DUE, or the DURATION. Without
/// either, a day for a date and nothing for a date-time (RFC 5545).
fn length(&self, c: &ICalendarComponent) -> Option<i64> {
let start = c.property(&ICalendarProperty::Dtstart)?;
let instant = |p: &ICalendarProperty| c.property(p).and_then(|e| self.instant(e));
match instant(&ICalendarProperty::Dtend).or_else(|| instant(&ICalendarProperty::Due)) {
Some(end) => Some(end - self.instant(start)?),
None => match c.property(&ICalendarProperty::Duration) {
Some(d) => match d.values.first()? {
ICalendarValue::Duration(d) => Some(d.as_seconds()),
_ => None,
},
None => {
let date = start.values.first()?.as_partial_date_time()?.hour.is_none();
Some(if date { 86400 } else { 0 })
}
},
}
}
/// Ends the series of master `m` before instant `at`: UNTIL in the form
/// RFC 5545 asks for, COUNT dropped, later RDATEs removed. `at` is an
/// instance, so COUNT had not ended before it.
fn end_series(&self, m: &mut ICalendarComponent, at: i64) {
let Some(start) = m.property(&ICalendarProperty::Dtstart).cloned() else {
return;
};
let Some(form) = start.values.first().and_then(|v| v.as_partial_date_time()) else {
return;
};
// Obj reads floating times in UTC, so `at` is their wall-clock time.
let until = if form.hour.is_none() {
PartialDateTime::from_date_timestamp(at - 86400)
} else if form.tz_hour.is_none() && start.tz_id().is_none() {
PartialDateTime::from_naive_timestamp(at - 1)
} else {
PartialDateTime::from_utc_timestamp(at - 1)
};
for e in m.entries.iter_mut() {
match e.name {
ICalendarProperty::Rrule => {
for v in &mut e.values {
if let ICalendarValue::RecurrenceRule(r) = v
&& !r
.until
.as_ref()
.and_then(|u| self.at(u, start.tz_id()))
.is_some_and(|u| u < at)
{
r.until = Some(until.clone());
r.count = None;
}
}
}
ICalendarProperty::Rdate => {
let tz = e.tz_id().map(str::to_string);
e.values.retain(|v| {
v.as_partial_date_time()
.and_then(|p| self.at(p, tz.as_deref()))
.is_none_or(|t| t < at)
});
}
_ => {}
}
}
m.entries
.retain(|e| e.name != ICalendarProperty::Rdate || !e.values.is_empty());
}
/// A message: this object's VCALENDAR and time zones around `comps`.
fn envelope(&self, comps: Vec<Node>, method: Method, now: DateTime<Utc>) -> ICalendar {
let mut root = Node {
@@ -1544,6 +1678,20 @@ fn attendees(c: &ICalendarComponent) -> impl Iterator<Item = &ICalendarEntry> {
c.properties(&ICalendarProperty::Attendee)
}
fn organizer_of(c: &ICalendarComponent) -> Option<String> {
c.property(&ICalendarProperty::Organizer)
.and_then(address)
.map(str::to_ascii_lowercase)
}
/// Whether `c` repeats without end.
pub(crate) fn endless(c: &ICalendarComponent) -> bool {
c.properties(&ICalendarProperty::Rrule).any(|e| {
matches!(e.values.first(), Some(ICalendarValue::RecurrenceRule(r))
if r.count.is_none() && r.until.is_none())
})
}
fn addresses(c: &ICalendarComponent) -> Vec<String> {
let mut v: Vec<String> = attendees(c)
.filter_map(address)
@@ -1559,8 +1707,8 @@ fn is_range(c: &ICalendarComponent) -> bool {
.is_some_and(|e| e.parameter(&ICalendarParameterName::Range).is_some())
}
fn lists(c: &ICalendarComponent, a: &str) -> bool {
attendees(c).any(|e| address(e).is_some_and(|x| x.eq_ignore_ascii_case(a)))
fn client_scheduled(c: &ICalendarComponent, a: &str) -> bool {
attendees(c).any(|e| address(e).is_some_and(|x| x.eq_ignore_ascii_case(a)) && !server_agent(e))
}
fn same_attendee<'a>(c: &'a ICalendarComponent, e: &ICalendarEntry) -> Option<&'a ICalendarEntry> {
▾Mpimdav/src/object.rs
@@ -48,10 +48,7 @@ pub struct CalendarObject {
/// component types the collection takes.
pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::CalendarData)?;
// The parser accepts a body cut off before its END, and the store serves
// the body as it came.
let last = text.lines().rev().find(|l| !l.trim().is_empty());
if !last.is_some_and(|l| l.trim().eq_ignore_ascii_case("END:VCALENDAR")) {
if !ends_with(text, "END:VCALENDAR") {
return Err(Invalid::CalendarData);
}
let mut parser = Parser::new(text);
@@ -211,6 +208,9 @@ fn too_many_rules(cal: &ICalendar) -> bool {
/// card without one is accepted: several clients omit it.
pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?;
if !ends_with(text, "END:VCARD") {
return Err(Invalid::AddressData);
}
let mut parser = Parser::new(text);
let Entry::VCard(card) = parser.entry() else {
return Err(Invalid::AddressData);
@@ -224,6 +224,15 @@ pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
.map(str::to_string))
}
/// Whether the last line of `text` is `end`. The parser accepts a body cut
/// off before its END, and the store serves the body as it came.
fn ends_with(text: &str, end: &str) -> bool {
text.lines()
.rev()
.find(|l| !l.trim().is_empty())
.is_some_and(|l| l.trim().eq_ignore_ascii_case(end))
}
/// `data` with `DTSTAMP:<now>` inserted after the BEGIN line of each VEVENT,
/// VTODO, VJOURNAL and VFREEBUSY that lacks it (RFC 5545 requires it).
/// Inserts text instead of re-serializing, so every other byte stays.
▾Mpimdav/src/render.rs
@@ -14,7 +14,7 @@ use calcard::vcard::{VCard, VCardVersion};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::expand::expand;
use crate::expand::{expand, stamp};
use crate::filter::{TimeRange, time_range};
use crate::freebusy::period;
use crate::report::Refused;
@@ -231,6 +231,30 @@ fn expanded(
}
out[0].component_ids.push(at);
}
// A task or journal without DTSTART has no instances to expand. Its
// times still go to UTC: the answer holds no VTIMEZONE.
let zones = Zones::new(cal, floating.clone());
for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) {
let c = &cal.components[i as usize];
if c.component_type != ICalendarComponentType::VTimezone
&& !c.has_property(&ICalendarProperty::Dtstart)
{
let at = copy(cal, i as usize, None, &mut out);
for e in &mut out[at as usize].entries {
let tzid = e.tz_id().map(str::to_string);
for v in &mut e.values {
if let ICalendarValue::PartialDateTime(p) = v
&& p.hour.is_some()
&& let Some(s) = stamp(&zones, p, tzid.as_deref())
{
**p = PartialDateTime::from_utc_timestamp(s.utc().timestamp());
}
}
e.params.retain(|p| p.name != ICalendarParameterName::Tzid);
}
out[0].component_ids.push(at);
}
}
Ok((ICalendar { components: out }, count))
}
@@ -382,11 +406,33 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
let mut out = String::new();
let _ = VCard { entries }.write_to(&mut out, version);
match version {
VCardVersion::V4_0 => out,
VCardVersion::V4_0 => raw_data_uris(&out),
_ => apple_forms(&out, version),
}
}
/// calcard's 4.0 writer escapes the comma of a `data:` URI as TEXT, which
/// breaks the URI. Binary values have no backslash to keep.
fn raw_data_uris(text: &str) -> String {
let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut out = String::with_capacity(text.len());
for raw in crate::text::logical_lines(text) {
let line = crate::text::unfold(raw);
let start = crate::text::value_start(&line);
let name = crate::text::name(&line);
let name = name.rsplit('.').next().unwrap_or_default();
let uri = ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name)
&& line[start..]
.get(..5)
.is_some_and(|v| v.eq_ignore_ascii_case("data:"));
match uri && line.contains("\\,") {
true => out.push_str(&crate::text::fold(&line.replace("\\,", ","), eol)),
false => out.push_str(raw),
}
}
out
}
/// The version a GET asks for in its `Accept` header: 4.0 when named,
/// else 3.0, which RFC 6352 makes the default and Apple Contacts needs.
pub fn accepted_version(accept: Option<&str>) -> VCardVersion {
▾Mpimdav/tests/bundle.rs
@@ -61,14 +61,18 @@ fn busy_time_hides_what_blocks_nothing() {
"BEGIN:VEVENT\r\nUID:free\r\nDTSTART:20240105T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n",
);
let busy = bundle::calendar(&[&series, &free], None, Detail::Busy);
// The master and the moved instance stay; the cancelled one is excluded.
assert_eq!(busy.matches("BEGIN:VEVENT").count(), 2, "{busy}");
assert!(busy.contains("EXDATE:20240102T100000Z\r\n"), "{busy}");
assert!(!busy.contains("RANGE"));
// The master and the moved instance stay; the cancelled range stays as
// an override, so it frees its instance and the later ones.
assert_eq!(busy.matches("BEGIN:VEVENT").count(), 3, "{busy}");
assert!(!busy.contains("EXDATE"), "{busy}");
assert!(busy.contains("RANGE=THISANDFUTURE") && busy.contains("STATUS:CANCELLED"));
assert!(!busy.contains("corp.example") && !busy.contains("UID:free"));
let uids: Vec<&str> = busy.lines().filter(|l| l.starts_with("UID:")).collect();
assert_eq!(uids.len(), 2);
assert_eq!(uids[0], uids[1], "master and override keep one UID");
assert_eq!(uids.len(), 3);
assert!(
uids.iter().all(|u| *u == uids[0]),
"master and overrides keep one UID"
);
}
#[test]
@@ -272,3 +276,52 @@ fn busy_time_writes_exdates_once_and_import_drops_cut_parts() {
let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD\r\nBEGIN:VCARD\r\nFN:B\r\n";
assert_eq!(bundle::split_cards(cards, &mut uids()).len(), 1);
}
#[test]
fn a_second_master_of_a_uid_gets_an_object_of_its_own() {
let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T100000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240201T100000Z\r\nSUMMARY:two\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T100000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap();
assert_eq!(parts.len(), 2, "{parts:?}");
assert!(parts[0].contains("UID:m\r\n") && parts[0].contains("SUMMARY:one"));
assert!(parts[1].contains("UID:new-1\r\n") && parts[1].contains("SUMMARY:two"));
for p in &parts {
assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}");
}
}
#[test]
fn an_override_follows_the_master_that_has_its_instance() {
let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240102T100000Z\r\nDTSTART:20240102T120000Z\r\nSUMMARY:A1\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240201T090000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:B\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240202T090000Z\r\nDTSTART:20240202T150000Z\r\nSUMMARY:B1\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap();
assert_eq!(parts.len(), 2, "{parts:?}");
assert!(parts[0].contains("SUMMARY:A1") && !parts[0].contains("SUMMARY:B"));
assert!(parts[1].contains("UID:new-1\r\n") && parts[1].contains("SUMMARY:B1"));
assert!(!parts[1].contains("UID:m\r\n"), "{}", parts[1]);
for p in &parts {
assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}");
}
}
#[test]
fn a_free_this_and_future_override_frees_the_later_instances() {
let object = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\n\
DTSTART:20240103T100000Z\r\nDURATION:PT1H\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let feed = bundle::calendar(&[object], None, Detail::Busy);
assert!(!feed.contains("EXDATE"), "{feed}");
let cal = pimdav::calcard::icalendar::ICalendar::parse(&feed).unwrap();
let range = "2024-01-01T00:00:00Z".parse().unwrap().."2024-01-10T00:00:00Z".parse().unwrap();
let busy = pimdav::freebusy::busy(&cal, &range, &pimdav::zone::Zone::Utc, None);
assert_eq!(busy.len(), 2, "{busy:?}");
}
▾Mpimdav/tests/expand.rs
@@ -432,3 +432,17 @@ fn a_this_and_future_move_across_dst_keeps_the_local_time() {
]
);
}
#[test]
fn the_rules_of_one_series_share_one_budget() {
// Each rule alone stays under the cap; together they pass it.
let body = event(
"a",
"DTSTART:20260601T000000Z\r\nRRULE:FREQ=SECONDLY\r\nRRULE:FREQ=SECONDLY;BYSECOND=0,10,20,30,40,50\r\n",
);
let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
let cal = ICalendar::parse(&ics).unwrap();
// 11 days with the slack: 950,400 plus 95,040 occurrences.
let days = utc("2026-06-01T00:00")..utc("2026-06-11T00:00");
assert!(expand(&cal, days, Zone::Utc).truncated);
}
▾Mpimdav/tests/itip.rs
@@ -907,3 +907,175 @@ fn a_cancelled_range_cancels_the_later_instances() {
.collect();
assert_eq!(cancelled, [false, true, true, true], "{}", text(&got));
}
#[test]
fn a_range_an_attendee_is_left_out_of_ends_their_series() {
use pimdav::expand::expand;
use pimdav::zone::Zone;
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\
DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let bob = &to(&msgs, BOB).unwrap().cal;
let starts: Vec<_> = expand(
bob,
at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90),
Zone::Utc,
)
.instances
.iter()
.map(|i| i.start)
.collect();
assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob));
let carol = &to(&msgs, CAROL).unwrap().cal;
let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
assert_eq!(expand(carol, window, Zone::Utc).instances.len(), 8);
}
#[test]
fn a_reply_status_keeps_only_its_code() {
let mut org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
let reply = cal(&format!(
"METHOD:REPLY\nBEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\n\
ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nREQUEST-STATUS:2.0\",x:EVIL;Success\nEND:VEVENT\n"
));
assert!(itip::apply_reply(&mut org, &reply, &is(BOB)));
let org = text(&org);
assert!(
org.contains(&format!("SCHEDULE-STATUS=2.0:{BOB}")) && !org.contains("EVIL"),
"{org}"
);
}
#[test]
fn attendees_may_not_change_an_instance_through_its_override() {
let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\n";
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
let over = |start: &str, end: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:2026011{start}00Z\n\
DTEND:2026011{end}00Z\n{people}END:VEVENT\n"
)
};
let copy = cal(&format!("{base}{people}END:VEVENT\n"));
// A new override at the right start but longer.
let longer = cal(&format!(
"{base}{people}END:VEVENT\n{}",
over("2T1000", "2T1200")
));
assert_eq!(
itip::attend(&copy, longer, &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
// Dropping the organizer's move of an instance.
let moved = cal(&format!(
"{base}{people}END:VEVENT\n{}",
over("2T1400", "2T1500")
));
assert_eq!(
itip::attend(&moved, copy.clone(), &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
// Dropping an override that only held a status is fine.
let own = cal(&format!(
"{base}{people}END:VEVENT\n{}",
over("2T1000", "2T1100")
));
assert!(itip::attend(&own, copy, &is(BOB), now()).is_ok());
}
#[test]
fn attendees_may_delete_an_instance_or_drop_one_of_their_instances() {
let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\n";
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
let moved = format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T140000Z\n\
DTEND:20260112T150000Z\n{people}END:VEVENT\n"
);
// Deleting the instance the organizer moved: EXDATE and no override.
let copy = cal(&format!("{base}{people}END:VEVENT\n{moved}"));
let deleted = cal(&format!(
"{base}EXDATE:20260112T100000Z\n{people}END:VEVENT\n"
));
assert!(itip::attend(&copy, deleted, &is(BOB), now()).is_ok());
// A copy of single instances: dropping one of them.
let other = moved.replace("20260112T1", "20260119T1").replace(
"RECURRENCE-ID:20260119T140000Z",
"RECURRENCE-ID:20260119T100000Z",
);
let single = cal(&format!("{moved}{other}"));
assert!(itip::attend(&single, cal(&other), &is(BOB), now()).is_ok());
}
#[test]
fn an_override_without_end_has_the_default_length() {
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
for (master, rid, start, end) in [
(
"DTSTART;VALUE=DATE:20260105",
"RECURRENCE-ID;VALUE=DATE:20260112",
"DTSTART;VALUE=DATE:20260112",
"DTEND;VALUE=DATE:20260113",
),
(
"DTSTART:20260105T100000Z",
"RECURRENCE-ID:20260112T100000Z",
"DTSTART:20260112T100000Z",
"DTEND:20260112T100000Z",
),
] {
let series = format!(
"BEGIN:VEVENT\nUID:m1\n{master}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
);
let copy = cal(&series);
let own = cal(&format!(
"{series}BEGIN:VEVENT\nUID:m1\n{rid}\n{start}\n{end}\n{people}END:VEVENT\n"
));
assert!(
itip::attend(&copy, own, &is(BOB), now()).is_ok(),
"{master}"
);
}
}
#[test]
fn attendees_may_not_drop_a_cancelled_instance() {
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n\
END:VEVENT\n"
);
let cancelled = cal(&format!(
"{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\n\
DTSTART:20260112T100000Z\nDTEND:20260112T110000Z\nSTATUS:CANCELLED\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
));
assert_eq!(
itip::attend(&cancelled, cal(&series), &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
}
#[test]
fn a_range_from_the_first_instance_cancels_the_whole_series() {
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260105T100000Z\n\
DTSTART:20260105T120000Z\nDTEND:20260105T130000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let old = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(Some(&old), Some(org), &is(ALICE), now());
let bob = to(&msgs, BOB).unwrap();
assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal));
}
▾Mpimdav/tests/protocol.rs
@@ -201,6 +201,8 @@ fn vcards() {
object::vcard(format!("{card}{card}").as_bytes()),
Err(Invalid::AddressData)
);
let cut = card.replace("END:VCARD\r\n", "");
assert_eq!(object::vcard(cut.as_bytes()), Err(Invalid::AddressData));
}
#[test]
▾Mpimdav/tests/report.rs
@@ -489,3 +489,67 @@ fn many_overrides_expand_once_per_query() {
started.elapsed()
);
}
#[test]
fn an_open_range_on_an_endless_rule_needs_no_expansion() {
let body = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20000101T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=MINUTELY\r\n\
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n";
let open = r#"<c:time-range start="20300101T000000Z"/>"#;
let event = format!(r#"<c:comp-filter name="VEVENT">{open}</c:comp-filter>"#);
let alarm = format!(
r#"<c:comp-filter name="VEVENT"><c:comp-filter name="VALARM">{open}</c:comp-filter></c:comp-filter>"#
);
let t = std::time::Instant::now();
assert!(hit(body, &event));
assert!(hit(body, &alarm));
assert!(
t.elapsed() < std::time::Duration::from_millis(500),
"{:?}",
t.elapsed()
);
}
#[test]
fn a_photo_data_uri_survives_the_conversion_to_4_0() {
let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p\r\nFN:Pic\r\n\
PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRg==\r\nEND:VCARD\r\n";
let v4 = AddressData {
props: None,
version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
};
let out = address_data(card, &v4).replace("\r\n ", "");
assert!(out.contains("data:image/jpeg;base64,/9j/"), "{out}");
assert!(!out.contains("\\,"), "{out}");
}
#[test]
fn expand_keeps_a_task_without_start() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nDUE:20260105T100000Z\r\n\
SUMMARY:Pay\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
let req = pimdav::render::CalendarData {
expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")),
..Default::default()
};
let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
assert!(
out.contains("BEGIN:VTODO") && out.contains("SUMMARY:Pay"),
"{out}"
);
}
#[test]
fn expand_puts_a_kept_task_in_utc() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\
DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\
END:STANDARD\r\nEND:VTIMEZONE\r\n\
BEGIN:VTODO\r\nUID:t\r\nDUE;TZID=Europe/Berlin:20260105T100000\r\n\
SUMMARY:Pay\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
let req = pimdav::render::CalendarData {
expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")),
..Default::default()
};
let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
assert!(out.contains("DUE:20260105T090000Z"), "{out}");
assert!(!out.contains("TZID"), "{out}");
}
▾Mserver/src/api/pim.rs
@@ -22,7 +22,7 @@ use std::sync::Arc;
use api_types::PIM;
use axum::body::Body;
use axum::extract::State;
use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION};
use axum::http::header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LOCATION};
use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
use axum::response::IntoResponse;
use percent_encoding::{
@@ -1109,7 +1109,9 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
]);
let home = |kind: PimKind| {
let name = match p.me {
true => space.map_or(p.path.clone(), |s| s.path.clone()),
true => space
.filter(|s| s.mine)
.map_or(p.path.clone(), |s| s.path.clone()),
false => p.path.clone(),
};
format!("{PIM}/{}/{}/", kind_segment(kind), seg(&name))
@@ -1620,16 +1622,6 @@ impl Cx<'_> {
{
return Ok(status(StatusCode::FORBIDDEN));
}
if self
.state
.db
.pim_collections(self.me.pid, *kind)
.await?
.len()
>= MAX_COLLECTIONS
{
return Ok(status(StatusCode::FORBIDDEN));
}
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
@@ -1674,6 +1666,11 @@ impl Cx<'_> {
xml::propstat_document(&root, &propstats),
));
}
let _lock = pim_schedule::LOCK.lock().await;
let count = self.state.db.pim_collections(self.me.pid, *kind).await?;
if count.iter().filter(|c| c.slug != INBOX).count() >= MAX_COLLECTIONS {
return Ok(status(StatusCode::FORBIDDEN));
}
if !self
.state
.db
@@ -1991,6 +1988,7 @@ impl Cx<'_> {
})
.await?;
}
let length = data.len().to_string();
let body = if head {
Body::empty()
} else {
@@ -2001,6 +1999,7 @@ impl Cx<'_> {
[
(CONTENT_TYPE, content_type(*kind, &o.component)),
(ETAG, o.etag),
(CONTENT_LENGTH, length),
],
body,
)
@@ -2532,7 +2531,8 @@ impl Cx<'_> {
if truncated {
responses.push(out.over_limit());
}
let token = sync_token(col.id, seq, issued.filter(|_| truncated));
// Past `issued`, the answer holds every change up to `seq`.
let token = sync_token(col.id, seq, issued.filter(|&i| truncated && seq <= i));
Ok(multistatus(
&responses,
Some(with_text(el(DAV, "sync-token"), token)),
▾Mserver/src/api/pim_api.rs
@@ -225,7 +225,9 @@ pub async fn create(
db_kind(body.kind),
&body.name,
color,
body.description.filter(|d| !d.trim().is_empty()),
body.description
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
&body.components,
)
.await?;
@@ -256,9 +258,6 @@ async fn create_collection(
{
return Err(bad_request("invalid description"));
}
if state.db.pim_collections(pid, kind).await?.len() >= MAX_COLLECTIONS {
return Err(ApiError::new(StatusCode::FORBIDDEN, "too many collections"));
}
let components = match kind {
PimKind::Calendar if components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::Calendar => {
@@ -277,9 +276,12 @@ async fn create_collection(
PimKind::AddressBook => String::new(),
};
let base = slug_of(name, kind);
let reserved = |s: &str| {
s.starts_with(SHARED_PREFIX) || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&s)
// A suffix would turn "shared" into the lent form "shared-2".
let base = match format!("{base}-").starts_with(SHARED_PREFIX) {
true => format!("own-{base}"),
false => base,
};
let reserved = [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&base.as_str());
let mut col = PimCollection {
displayname: Some(name.to_string()),
description,
@@ -287,9 +289,14 @@ async fn create_collection(
components,
..Default::default()
};
let _lock = pim_schedule::LOCK.lock().await;
let count = state.db.pim_collections(pid, kind).await?;
if count.iter().filter(|c| c.slug != INBOX).count() >= MAX_COLLECTIONS {
return Err(ApiError::new(StatusCode::FORBIDDEN, "too many collections"));
}
for n in 1..100 {
let slug = match n {
1 if !reserved(&base) => base.clone(),
1 if !reserved => base.clone(),
1 => continue,
n => format!("{base}-{n}"),
};
@@ -850,7 +857,7 @@ fn download(kind: PimKind, name: &str, body: String) -> Response {
///
/// Each object goes through the checks of a PUT and is skipped where a PUT
/// would fail. An object whose UID the collection already has replaces it.
/// Nothing is sent to attendees or organizers.
/// Scheduling runs as for a PUT.
pub async fn import(
State(state): State<Arc<AppState>>,
auth: SessionUser,
▾Mserver/src/api/pim_views.rs
@@ -261,6 +261,11 @@ pub async fn object(
is_owner: a.is_owner,
})
.collect(),
series_partstat: view::component_for(&cal, None, &zone).and_then(|m| {
view::event_info(&cal, m, &owns)
.partstat()
.map(str::to_string)
}),
can_edit: writable,
can_reply: attendee && answers,
})))
@@ -282,7 +287,8 @@ pub async fn object(
}
false => Vec::new(),
};
let photo_url = card.has_photo.then(|| {
// photo() reads stored objects only.
let photo_url = (card.has_photo && !generated(col.id)).then(|| {
format!(
"{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
seg(&obj.name)
@@ -397,7 +403,7 @@ pub async fn contacts(
org: c.org,
email: c.emails.into_iter().next().map(|e| e.value),
phone: c.phones.into_iter().next().map(|p| p.value),
has_photo: c.has_photo,
has_photo: c.has_photo && !generated(id),
is_group: c.is_group,
});
}
▾Mserver/tests/api_pim.rs
@@ -454,6 +454,8 @@ async fn calendar_objects() {
let r = req(&env, "HEAD", &obj, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.body.is_empty());
let length = event("a", "One").len().to_string();
assert_eq!(r.header("content-length"), Some(length));
let r = req(
&env,
@@ -1609,6 +1611,18 @@ async fn principal_search() {
let r = req(&env, "REPORT", principals, &alice, &[], cs).await;
assert_eq!(hrefs_in(&r), ["/pim/principals/board/"]);
// At another principal, the own hit still names the own home.
let own = r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:property-search><d:prop><d:displayname/></d:prop><d:match>alice</d:match></d:property-search>
<d:prop><c:calendar-home-set/></d:prop>
</d:principal-property-search>"#;
let r = req(&env, "REPORT", "/pim/principals/bob/", &alice, &[], own).await;
assert!(
r.text().contains("/pim/calendars/alice/") && !r.text().contains("/pim/calendars/bob/"),
"{}",
r.text()
);
let set = r#"<d:principal-search-property-set xmlns:d="DAV:"/>"#;
let r = req(&env, "REPORT", principals, &alice, &[], set).await;
assert_eq!(r.status, StatusCode::OK);
@@ -1764,6 +1778,34 @@ async fn a_cut_initial_sync_resumes_past_pruned_tombstones() {
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
}
#[tokio::test]
async fn a_cut_initial_sync_survives_writes_between_its_pages() {
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:limit><d:nresults>1</d:nresults></d:limit><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"] {
put(&env, &auth, &obj(n), &todo(n)).await;
}
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
let mut token = sync_token_of(&r);
for n in ["c", "d"] {
put(&env, &auth, &obj(n), &todo(n)).await;
}
// b, then c (written after the sync began), then d.
for n in ["b", "c", "d"] {
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{n}: {}", r.text());
assert!(r.text().contains(&format!("{n}.ics")), "{}", r.text());
token = sync_token_of(&r);
}
}
#[tokio::test]
async fn mkcol_checks_target_and_values() {
let (env, auth) = setup().await;
@@ -1898,7 +1940,7 @@ async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
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.
// The cap counts the default calendar, not the inbox.
let mut n = 0;
loop {
let r = req(&env, "MKCALENDAR", &format!("{HOME}c{n}/"), &auth, &[], "").await;
@@ -1907,11 +1949,11 @@ async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
break;
}
n += 1;
assert!(n < 100, "no limit");
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);
assert_eq!(all.unwrap().len(), 101);
let r = req(
&env,
"MKCOL",
▾Mserver/tests/api_pim_io.rs
@@ -749,6 +749,19 @@ async fn the_web_api_keeps_the_dav_limits() {
let r = io.import(cal, &file).await;
assert_eq!(r["created"], 150, "{r}");
// A suffixed slug never takes the lent form "shared-<id>".
for _ in 0..2 {
let r = io
.alice
.post_json(
"/api/pim/collections",
&json!({"kind": "calendar", "name": "Shared"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(!r.text().contains("/shared-"), "{}", r.text());
}
// No more collections than MKCOL allows.
let create = async |name: String| {
io.alice
▾Mserver/tests/api_pim_ui.rs
@@ -363,6 +363,7 @@ async fn object_detail_for_events_and_contacts() {
.await
.json();
assert_eq!(d["can_reply"], true, "{d}");
assert_eq!(d["series_partstat"], "NEEDS-ACTION", "{d}");
// Someone else's object stays hidden.
let r = ui
.bob
▾Mweb/src/router.rs
@@ -429,6 +429,15 @@ pub fn replace(loc: &Location) {
replace_url(&location_to_hash(loc));
}
/// Like [`replace`], and drops the open mark, so closing the open file
/// replaces this entry instead of going Back to an older one.
pub fn replace_unmarked(loc: &Location) {
if let Some(h) = history() {
let _ = h.replace_state_with_url(&JsValue::NULL, "", Some(&location_to_hash(loc)));
}
sync();
}
pub fn replace_url(url: &str) {
replace_state(url);
sync();
▾Mweb/src/views/calendar.rs
@@ -436,12 +436,16 @@ impl CalView {
}
}
fn go(view: CalView) {
navigate(&Location {
fn location(view: CalView) -> Location {
Location {
section: Section::Calendar,
path: view.path(),
..Location::root()
});
}
}
fn go(view: CalView) {
navigate(&location(view));
}
/// The event whose details are open, as the URL names it:
@@ -675,6 +679,8 @@ pub fn CalendarMain(
})
};
let is_month = Memo::new(move |_| matches!(view.get(), CalView::Month(..)));
// The month view selects today again even when the month stays.
let today_clicks = RwSignal::new(0u32);
// The month shown, or this month from another tab.
let this_month = move || match view.get_untracked() {
v @ CalView::Month(..) => v,
@@ -722,6 +728,7 @@ pub fn CalendarMain(
on:click=move |_| {
let (y, m, _) = civil_from_days(today());
go(CalView::Month(y, m));
today_clicks.update(|n| *n += 1);
}
>
{i18n::t(k::PIM_TODAY)}
@@ -738,14 +745,20 @@ pub fn CalendarMain(
})}
<h2 class="cal-title">{title}</h2>
<div class="cal-views" role="tablist" on:keydown=move |ev| {
let step = match ev.key().as_str() {
"ArrowRight" => 1,
"ArrowLeft" => -1,
if ev.alt_key() || ev.ctrl_key() || ev.meta_key() || ev.shift_key() {
return;
}
let at = tab_of(view.get_untracked());
let to = match ev.key().as_str() {
"ArrowRight" => (at + 1) % 3,
"ArrowLeft" => (at + 2) % 3,
"Home" => 0,
"End" => 2,
_ => return,
};
ev.prevent_default();
let to = (tab_of(view.get_untracked()) as i32 + step).rem_euclid(3) as usize;
go(tab_at(to));
// Moving between tabs with keys adds no history entries.
router::replace(&location(tab_at(to)));
let tab = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(TAB_IDS[to]))
@@ -777,7 +790,15 @@ 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 on_retry=refresh shown=shown/>
<MonthView
y=y
m=m
week_start=week_start.get()
data=month
on_retry=refresh
shown=shown
today_clicks=today_clicks
/>
}
.into_any(),
CalView::Agenda => view! {
@@ -866,6 +887,7 @@ fn MonthView(
data: RwSignal<Option<Result<api::PimInstances, String>>>,
on_retry: Callback<()>,
shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
today_clicks: RwSignal<u32>,
) -> impl IntoView {
let start = grid_start(y, m, week_start);
let now = today();
@@ -878,6 +900,12 @@ fn MonthView(
} else {
days_from_civil(y, m, 1)
});
Effect::new(move |seen: Option<()>| {
today_clicks.track();
if seen.is_some() && in_month(today()) {
selected.set(today());
}
});
let list_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let pick = move |day: i64| {
selected.set(day);
@@ -1204,7 +1232,7 @@ fn InvitationsView(
let series = inv.recurring && inv.recurrence_id.is_none();
let mut meta = vec![fmt_span(&span)];
match (&inv.rrule, &inv.recurrence_id) {
(Some(r), None) => meta.push(rrule_text(&with_start_day(r, span.first))),
(Some(r), None) => meta.push(rrule_text(r, Some(span.first))),
(None, Some(_)) => meta.push(i18n::t(k::PIM_THIS_EVENT).to_string()),
_ => {}
}
@@ -1265,8 +1293,11 @@ fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) {
/// `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` as words; a rule it cannot put into
/// words stays raw.
fn rrule_text(rule: &str) -> String {
rrule_words(rule, weekday_name, month_name, until_date).unwrap_or_else(|| rule.to_string())
/// `rule` in words, with the weekday of `first` when it names none, or
/// else `rule` itself.
fn rrule_text(rule: &str, first: Option<i64>) -> String {
let full = first.map_or_else(|| rule.to_string(), |f| with_start_day(rule, f));
rrule_words(&full, weekday_name, month_name, until_date).unwrap_or_else(|| rule.to_string())
}
fn rrule_words(
@@ -1280,9 +1311,10 @@ fn rrule_words(
.find_map(|p| p.strip_prefix(key)?.strip_prefix('='))
};
let list = |key: &str| part(key).into_iter().flat_map(|v| v.split(','));
let n = part("INTERVAL")
.and_then(|n| n.parse::<u32>().ok())
.unwrap_or(1);
let n = match part("INTERVAL") {
Some(n) => n.parse::<u32>().ok().filter(|&n| n > 0)?,
None => 1,
};
let freq = part("FREQ");
let keys = match freq {
Some("DAILY") => (k::PIM_RRULE_DAILY, k::PIM_RRULE_DAILY_N),
@@ -1442,6 +1474,9 @@ fn EventDialog(
let detail = RwSignal::new(Option::<Result<PimEventDetail, String>>::None);
let busy = RwSignal::new(false);
let load_seq = StoredValue::new(0u32);
// For one instance of a series: answer it alone (`true`), or the whole
// series. Set on the first load, so a reply does not flip it.
let chosen = RwSignal::new(None::<bool>);
let load = {
let t = target.clone();
move || {
@@ -1465,15 +1500,26 @@ fn EventDialog(
Err(e) => Err(e.to_string()),
};
if load_seq.try_get_value() == Some(seq) {
let _ = detail.try_set(Some(r));
// A reload after a reply keeps the event shown.
match r {
Err(e) if matches!(detail.try_get_untracked(), Some(Some(Ok(_)))) => {
show_error(toast, e)
}
r => {
// An unanswered series came as one invitation for all of it.
if let (Ok(e), Some(None)) = (&r, chosen.try_get_untracked()) {
let whole = e.series_partstat.as_deref() == Some("NEEDS-ACTION");
let _ = chosen.try_set(Some(!whole));
}
let _ = detail.try_set(Some(r));
}
}
let _ = busy.try_set(false);
}
});
}
};
load();
// For one instance of a series: answer it alone, or the whole series.
let only_this = RwSignal::new(true);
let collection_id = target.collection_id;
let collection = Memo::new(move |_| {
collections.with(|c| {
@@ -1483,9 +1529,12 @@ fn EventDialog(
.map(|c| (display_name(c), safe_color(c.color.as_deref())))
})
});
// Every state carries the element that names the dialog.
let body = move || match detail.get() {
None => view! { <p class="muted">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <p class="form-error">{e}</p> }.into_any(),
None => {
view! { <p id="cal-detail-title" class="muted">{i18n::t(k::LOADING)}</p> }.into_any()
}
Some(Err(e)) => view! { <p id="cal-detail-title" class="form-error">{e}</p> }.into_any(),
Some(Ok(d)) => {
let title = d
.summary
@@ -1497,11 +1546,13 @@ fn EventDialog(
.find(|a| a.is_owner)
.and_then(|a| a.partstat.clone());
let series = target.recurrence_id.is_some() && d.rrule.is_some();
let only_this = move || chosen.get().unwrap_or(true);
let rid = target.recurrence_id.clone();
let (id, name) = (target.collection_id, target.name.clone());
let load = load.clone();
let answer = move |ps: &'static str| {
let rid = if only_this.get_untracked() {
// Without the choice, the instance alone.
let rid = if !series || chosen.get_untracked().unwrap_or(true) {
rid.clone()
} else {
None
@@ -1538,8 +1589,8 @@ fn EventDialog(
})}
{d.rrule.clone().map(|r| {
let text = match d.start.as_deref().zip(d.end.as_deref()) {
Some((s, e)) => rrule_text(&with_start_day(&r, span_at(s, e, d.all_day).first)),
None => rrule_text(&r),
Some((s, e)) => rrule_text(&r, Some(span_at(s, e, d.all_day).first)),
None => rrule_text(&r, None),
};
view! {
<dt>{i18n::t(k::PIM_REPEATS)}</dt>
@@ -1603,8 +1654,8 @@ fn EventDialog(
<input
type="radio"
name="cal-scope"
prop:checked=move || only_this.get()
on:change=move |_| only_this.set(true)
prop:checked=only_this
on:change=move |_| chosen.set(Some(true))
/>
{i18n::t(k::PIM_THIS_EVENT)}
</label>
@@ -1612,8 +1663,8 @@ fn EventDialog(
<input
type="radio"
name="cal-scope"
prop:checked=move || !only_this.get()
on:change=move |_| only_this.set(false)
prop:checked=move || !only_this()
on:change=move |_| chosen.set(Some(false))
/>
{i18n::t(k::PIM_ALL_EVENTS)}
</label>
@@ -1815,6 +1866,8 @@ mod tests {
"FREQ=DAILY;BYHOUR=9,17",
"FREQ=WEEKLY;X-FOO=1",
"FREQ=MONTHLY;BYDAY=MO",
"FREQ=WEEKLY;INTERVAL=0",
"FREQ=WEEKLY;INTERVAL=abc",
"FREQ=YEARLY;BYMONTH=3;BYDAY=SU",
"FREQ=DAILY;BYDAY=MO,TU",
"FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13",
▾Mweb/src/views/contacts.rs
@@ -138,7 +138,8 @@ pub fn ContactsMain(
return;
}
l.search = v.trim().to_string();
router::replace(&l);
// Back from the open contact would restore the old query.
router::replace_unmarked(&l);
})));
};
▾Mweb/src/views/pim.rs
@@ -571,6 +571,7 @@ fn NewCollectionDialog(
<textarea
class="field-input"
rows="2"
maxlength="1024"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
@@ -677,6 +678,8 @@ fn GeneralSection(
let (name, set_name) = signal(info.name.clone());
let color = RwSignal::new(info.color.clone());
let (description, set_description) = signal(info.description.clone().unwrap_or_default());
// Sent only when changed: one a client stored may fail the server's check.
let saved_description = StoredValue::new(description.get_untracked());
let (transparent, set_transparent) = signal(info.transparent);
let (busy, set_busy) = signal(false);
let save = move |ev: web_sys::SubmitEvent| {
@@ -689,12 +692,16 @@ fn GeneralSection(
let body = UpdatePimCollection {
name: Some(name.get()),
color: Some(color.get().unwrap_or_default()),
description: Some(description.get()),
description: Some(description.get()).filter(|d| *d != saved_description.get_value()),
transparent: calendar.then(|| transparent.get()),
};
let sent = body.description.clone();
spawn_local(async move {
match api::pim_update_collection(id, body).await {
Ok(_) => {
if let Some(d) = sent {
let _ = saved_description.try_set_value(d);
}
show(toast, i18n::t(k::SAVED));
let _ = on_saved.try_run(saved);
let _ = on_changed.try_run(());
@@ -726,6 +733,7 @@ fn GeneralSection(
<textarea
class="field-input"
rows="2"
maxlength="1024"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
▾Mweb/src/views/shares.rs
@@ -751,7 +751,9 @@ fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
let (others_err, set_others_err) = signal(Option::<String>::None);
let load_seq = StoredValue::new(0u32);
let load = move || {
let seq = load_seq.get_value() + 1;
let Some(seq) = load_seq.try_get_value().map(|s| s + 1) else {
return;
};
load_seq.set_value(seq);
set_load_err.set(None);
spawn_local(async move {