Calendar and contacts web UI, part 1: JSON endpoints and management

- Collections: POST, PUT and DELETE /api/pim/collections; the list now
  carries color, description, components, default flag, share and link
  counts, and the generated birthday calendar (-1) and directory (0)
- Views for part 2: /api/pim/instances, object detail, /api/pim/contacts,
  /api/pim/invitations with a reply that goes through the normal PUT path,
  and /api/pim/preview for .ics/.vcf files in a root
- pimdav: view module (event and contact summaries), itip::respond,
  zone::by_name; collection deletion shared by DAV and JSON
- Web: Calendar and Contacts pages with the collection list, manage
  dialog (settings, sharing, public links, export, delete), app setup
  help, file import dialog, rooms on the Users page, feeds on Shares
- EN, DE and FR strings; README and pimdav README updated

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit3108d1f7ade183e2cbef98080b2a49dcdc6177d5
Parent39fbe17
24 files changed, 5150 insertions(+), 101 deletions(-)
▾MREADME.md
@@ -271,6 +271,23 @@ result is kept; without it the photo is made on each request. Only a
photo stored inside the contact counts. A photo given as a web address is
never fetched.
### Web UI
The sidebar has a Calendar and a Contacts page. Each lists your own
collections, the generated ones and the ones lent to you. The color square
hides or shows a collection. A click on a name opens its dialog: name,
color, description, "show as free", sharing, public links, export and
delete. "Connect an app" shows the addresses and user name for apps.
Admins find rooms and resources on the Users page, and every public feed
on the Shares page.
The views behind these pages use JSON endpoints, so the browser never
parses iCalendar or vCard: `/api/pim/instances` (occurrences in a range of
at most 400 days), `/api/pim/collections/<id>/objects/<name>` (one event
or contact), `/api/pim/contacts` (search), `/api/pim/invitations`
(unanswered invitations; `POST` answers one like an app would) and
`/api/pim/preview` (what a file in a folder holds).
### Sharing
You can lend a calendar or address book to another account. It then shows
@@ -287,8 +304,8 @@ your name is refused. With `rw+schedule`, the messages name the other
account as the sender (`SENT-BY`). Only the owner can rename a collection
or change its color.
The web UI has no page for this yet. The JSON API is
`GET /api/pim/collections`, then `GET` or `POST
In the web UI, open the collection from the Calendar or Contacts page.
The JSON API is `GET /api/pim/collections`, then `GET` or `POST
/api/pim/collections/<id>/shares` with `{"user": "<name>", "mode":
"rw"}`, and `DELETE /api/pim/collections/<id>/shares/<user id>` to end a
loan.
@@ -362,8 +379,12 @@ your folders. You need write access: your own collection, or a `rw` or
book as one file, with every event in full, private ones included. `POST` with
`{"root_id": <id>, "path": "<path>"}` saves it as a new file into a
writable folder. An existing file is not overwritten. Lent collections can
be exported too. `GET` and `POST /api/pim/system/export` do the same for
the system address book. The birthday calendar cannot be exported.
be exported too, and so are the birthday calendar (id `-1`) and the
system address book (id `0`, also at `/api/pim/system/export`).
In the web UI, right-click an `.ics` or `.vcf` file and choose "Import
into calendar or contacts…". The dialog shows what the file holds first.
Export is in each collection's dialog.
### Invitations
@@ -392,8 +413,8 @@ address book or their attendee search.
### Rooms and resources
Admins manage rooms and resources with `GET` and `POST /api/admin/rooms`
and `PUT` and `DELETE /api/admin/rooms/<id>`. A room's name is fixed,
Admins manage rooms and resources on the Users page, or with `GET` and
`POST /api/admin/rooms` and `PUT` and `DELETE /api/admin/rooms/<id>`. A room's name is fixed,
because it is part of its address. Its display name can change. Rooms and
accounts share one set of names.
▾Mapi-types/src/lib.rs
@@ -75,8 +75,10 @@ pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
/// `{ADMIN_PIM_LINKS}` and `{ADMIN_PIM_LINKS}/{id}`.
pub const ADMIN_PIM_LINKS: &str = "/api/admin/pim-links";
/// The signed-in user's calendars and address books, own and lent to them
/// (`GET`). `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends
/// (`POST`) an own one; `DELETE` on `.../{user_id}` below it ends a loan.
/// (`GET`), and a new one (`POST`). `PUT` and `DELETE` on `{PIM_COLLECTIONS}/{id}`
/// change or delete an own one; `DELETE` on a lent one ends the loan.
/// `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends (`POST`)
/// an own one; `DELETE` on `.../{user_id}` below it ends a loan.
pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
pub const SHARES_SUFFIX: &str = "/shares";
/// `{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`: the public feeds of an own
@@ -91,10 +93,23 @@ pub const EXPORT_SUFFIX: &str = "/export";
/// The system address book, which has no collection id: `GET` downloads it,
/// `POST` with a [`PimRootFile`] saves it into a root.
pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`: a
/// contact's photo as a WebP thumbnail.
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`: one event or contact
/// as [`PimObjectDetail`]. `{...}/{name}{PHOTO_SUFFIX}`: a contact's photo as
/// a WebP thumbnail.
pub const OBJECTS_SUFFIX: &str = "/objects";
pub const PHOTO_SUFFIX: &str = "/photo";
/// `GET`: the instances of the readable calendars in a time range, as
/// [`PimInstances`]. Params `from`, `to` (RFC 3339), `tz`, `collections`.
pub const PIM_INSTANCES: &str = "/api/pim/instances";
/// `GET`: the contacts of the readable address books, as [`PimContact`]s.
/// Params `q`, `collections`.
pub const PIM_CONTACTS: &str = "/api/pim/contacts";
/// `GET`: the invitations the signed-in user has not answered, as
/// [`PimInvitation`]s. `POST` a [`PimReply`] to answer one.
pub const PIM_INVITATIONS: &str = "/api/pim/invitations";
/// `GET`: what an `.ics` or `.vcf` file in a root holds, as [`PimPreview`].
/// Params `root`, `path`, `tz`.
pub const PIM_PREVIEW: &str = "/api/pim/preview";
/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
/// `.vcf`. The extension is optional.
pub const FEED: &str = "/feed";
@@ -150,6 +165,17 @@ pub const P_DIRS: &str = "dirs";
/// `?around=name` returns the page that holds this entry, instead of the
/// one at the offset. A missing name falls back to the offset.
pub const P_AROUND: &str = "around";
/// [`PIM_INSTANCES`]: the range, RFC 3339.
pub const P_FROM: &str = "from";
pub const P_TO: &str = "to";
/// [`PIM_INSTANCES`], [`PIM_PREVIEW`], object detail: the IANA zone that
/// all-day and floating times are read in. Default UTC.
pub const P_TZ: &str = "tz";
/// [`PIM_INSTANCES`], [`PIM_CONTACTS`]: comma-separated collection ids.
/// Default all readable ones.
pub const P_COLLECTIONS: &str = "collections";
/// Object detail: the instance of a series, as in [`PimInstance`].
pub const P_RECURRENCE_ID: &str = "recurrence_id";
// ---------------------------------------------------------------------------
// Wire enums
@@ -556,8 +582,10 @@ impl PimShareMode {
}
/// One entry of `GET {PIM_COLLECTIONS}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimCollectionInfo {
/// `0` for the system address book and `-1` for the birthday calendar,
/// which the server generates.
pub id: i64,
pub kind: PimCollectionKind,
pub name: String,
@@ -566,6 +594,249 @@ pub struct PimCollectionInfo {
pub owner: String,
/// `None` for an own collection, the loan's mode for a lent one.
pub mode: Option<PimShareMode>,
/// Generated by the server, so read-only.
#[serde(default)]
pub generated: bool,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub description: Option<String>,
/// Calendars: the component types it takes, e.g. `VEVENT`.
#[serde(default)]
pub components: Vec<String>,
/// Calendars: adds no busy time to scheduling.
#[serde(default)]
pub transparent: bool,
/// The calendar that receives invitations. It cannot be deleted.
#[serde(default)]
pub is_default: bool,
/// Own collections: how many accounts it is lent to.
#[serde(default)]
pub shares: usize,
/// Own collections: how many public feeds it has.
#[serde(default)]
pub links: usize,
}
/// `POST {PIM_COLLECTIONS}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CreatePimCollection {
pub kind: PimCollectionKind,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Calendars: `VEVENT`, `VTODO`, `VJOURNAL`. Empty takes all three.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<String>,
}
/// `PUT {PIM_COLLECTIONS}/{id}`: absent fields stay; an empty `color` or
/// `description` removes it.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct UpdatePimCollection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transparent: Option<bool>,
}
/// One occurrence of an event, task or journal entry: `GET {PIM_INSTANCES}`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimInstance {
pub collection_id: i64,
/// The object's resource name in the collection.
pub name: String,
pub uid: String,
/// The original start of a recurring instance (RFC 3339 UTC); `None`
/// for an object that does not recur.
pub recurrence_id: Option<String>,
/// RFC 3339 UTC. An all-day instance starts at midnight in `tz`.
pub start: String,
pub end: String,
pub all_day: bool,
/// `VEVENT`, `VTODO` or `VJOURNAL`.
pub component: String,
pub summary: Option<String>,
pub location: Option<String>,
/// `TENTATIVE`, `CONFIRMED`, `CANCELLED`, ...
pub status: Option<String>,
pub transparent: bool,
pub has_attendees: bool,
/// The calendar owner's PARTSTAT when they are an attendee.
pub partstat: Option<String>,
/// The organizer's name, else address.
pub organizer: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimInstances {
pub instances: Vec<PimInstance>,
/// Not every instance fits: the range held too many.
pub truncated: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimPerson {
pub name: Option<String>,
/// The calendar user address, e.g. `mailto:...`.
pub address: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimAttendee {
#[serde(flatten)]
pub person: PimPerson,
pub partstat: Option<String>,
pub role: Option<String>,
/// The calendar owner.
pub is_owner: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimEventDetail {
pub collection_id: i64,
pub name: String,
pub uid: String,
pub component: String,
pub summary: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
pub url: Option<String>,
pub status: Option<String>,
pub transparent: bool,
pub all_day: bool,
pub categories: Vec<String>,
/// The RRULE of the series, e.g. `FREQ=WEEKLY;BYDAY=MO`.
pub rrule: Option<String>,
pub organizer: Option<PimPerson>,
pub attendees: Vec<PimAttendee>,
/// 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
/// for them.
pub can_reply: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimLabeled {
/// `work`, `home`, a client's own label, ...
pub label: Option<String>,
pub value: String,
}
/// One entry of `GET {PIM_CONTACTS}`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimContact {
pub collection_id: i64,
pub name: String,
pub full_name: String,
pub org: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub has_photo: bool,
pub is_group: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimContactDetail {
pub collection_id: i64,
pub name: String,
pub uid: Option<String>,
pub full_name: String,
pub org: Option<String>,
pub title: Option<String>,
pub emails: Vec<PimLabeled>,
pub phones: Vec<PimLabeled>,
/// One address per entry, its parts on separate lines.
pub addresses: Vec<PimLabeled>,
pub urls: Vec<PimLabeled>,
/// `1980-03-15`, or `--03-15` without a year.
pub birthday: Option<String>,
pub anniversary: Option<String>,
pub note: Option<String>,
pub is_group: bool,
/// A group's members, by name where the address book knows them.
pub members: Vec<String>,
/// `{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`.
pub photo_url: Option<String>,
pub can_edit: bool,
}
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PimObjectDetail {
Event(PimEventDetail),
Contact(PimContactDetail),
}
/// One entry of `GET {PIM_INVITATIONS}`: a series, or one instance of it
/// when that instance was invited on its own.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimInvitation {
pub collection_id: i64,
pub name: String,
pub uid: String,
/// `None`: the whole series.
pub recurrence_id: Option<String>,
pub summary: Option<String>,
pub location: Option<String>,
pub organizer: Option<PimPerson>,
/// The next start, RFC 3339 UTC.
pub start: String,
pub end: String,
pub all_day: bool,
pub recurring: bool,
}
/// `POST {PIM_INVITATIONS}`: the calendar owner's answer. The server writes
/// it into their copy and tells the organizer, as a client would.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimReply {
pub collection_id: i64,
pub name: String,
/// Answer one instance only. As in [`PimInstance::recurrence_id`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurrence_id: Option<String>,
/// `ACCEPTED`, `TENTATIVE` or `DECLINED`.
pub partstat: String,
/// The IANA zone of the request that listed the instance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tz: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimPreviewEvent {
pub summary: Option<String>,
/// RFC 3339 UTC.
pub start: Option<String>,
pub all_day: bool,
pub recurring: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimPreviewContact {
pub full_name: String,
pub email: Option<String>,
pub phone: Option<String>,
}
/// `GET {PIM_PREVIEW}`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimPreview {
pub kind: PimCollectionKind,
/// All objects in the file, also those beyond the lists.
pub total: usize,
/// Objects that are not valid calendar or address data.
pub invalid: usize,
pub events: Vec<PimPreviewEvent>,
pub contacts: Vec<PimPreviewContact>,
}
/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
@@ -632,7 +903,7 @@ pub struct PimRootFile {
}
/// The answer to an import.
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimImportResult {
pub created: usize,
pub updated: usize,
@@ -642,7 +913,7 @@ pub struct PimImportResult {
pub skipped: Vec<PimSkipped>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimSkipped {
pub uid: Option<String>,
/// The precondition a PUT of the object would fail, e.g.
▾Mpimdav/README.md
@@ -26,6 +26,7 @@ The crate uses two libraries:
| `freebusy` | Busy time, free-busy replies |
| `itip` | Implicit scheduling as iTIP messages |
| `principal` | Principal search, system address book cards |
| `view` | Events and contacts summarized for a user interface |
| `contact` | Contact photos, birthday events |
| `bundle` | Whole collections as one file, and one file split into objects |
@@ -275,6 +276,10 @@ server. The caller maps addresses onto its principals through closures.
attendee may add EXDATEs, which declines those instances.
- Other attendee changes, such as SUMMARY or alarms, stay in the
attendee's copy.
- `itip::respond` answers for an attendee without an app: it sets their
PARTSTAT in every component, or in one instance, which then gets an
override of its own. Stored through the normal PUT path, the change
sends the REPLY.
### Delivery into copies
▾Mpimdav/src/itip.rs
@@ -217,6 +217,53 @@ pub fn auto_answer(
obj.done()
}
/// The attendee's copy with `me` answering `answer`: for every component, or
/// only for the instance at `instance`, which gets an override of its own
/// when it has none. `floating` reads the instance of an all-day series.
/// Storing the result through a PUT sends the REPLY.
pub fn respond(
copy: &ICalendar,
me: Is,
answer: ICalendarParticipationStatus,
instance: Option<DateTime<Utc>>,
floating: &Zone,
) -> ICalendar {
let mut obj = Obj::new(copy);
let target = instance.map(|rid| {
let master = obj.master().cloned();
let entry = master
.as_ref()
.and_then(|m| obj.recurrence_id(m, rid, floating));
let key = entry.as_ref().and_then(|e| obj.instant(e));
if obj.find(key).is_none()
&& let (Some(m), Some(e)) = (&master, &entry)
{
let inst = obj.instance(m, e);
obj.root.children.push(inst);
}
key
});
let keys: Vec<Option<i64>> = obj.comps().map(|c| obj.key(&c.c)).collect();
for (c, key) in obj.comps_mut().zip(&keys) {
if target.is_some_and(|t| t != *key) {
continue;
}
for e in
c.c.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Attendee && address(e).is_some_and(me))
{
set_param(
e,
ICalendarParameterName::Partstat,
partstat(answer.clone()),
);
remove_param(e, &ICalendarParameterName::Rsvp);
}
}
obj.done()
}
/// The messages a change of the organizer object sends, without touching
/// the attendee state in it. `force` lists attendees who get a REQUEST even
/// if nothing changed for them.
▾Mpimdav/src/lib.rs
@@ -12,6 +12,7 @@ pub mod principal;
pub mod render;
pub mod report;
mod text;
pub mod view;
pub mod xml;
pub mod zone;
▾Apimdav/src/view.rs
@@ -0,0 +1,436 @@
//! Plain summaries of calendar objects and contacts, for a user interface.
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendarProperty, ICalendarValue,
};
use chrono::{DateTime, Utc};
use crate::itip::Is;
use crate::text::{logical_lines, name, param, param_parts, unescape_text, unfold, value};
use crate::zone::{Zone, Zones};
#[derive(Debug, Clone, PartialEq)]
pub struct Person {
pub name: Option<String>,
pub address: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Attendee {
pub person: Person,
pub partstat: Option<String>,
pub role: Option<String>,
pub is_owner: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct EventInfo {
pub component: String,
pub summary: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
pub url: Option<String>,
pub status: Option<String>,
pub transparent: bool,
pub all_day: bool,
pub categories: Vec<String>,
/// The RRULE of the series the component belongs to.
pub rrule: Option<String>,
pub organizer: Option<Person>,
pub attendees: Vec<Attendee>,
}
impl EventInfo {
/// The owner's PARTSTAT, when they are an attendee.
pub fn partstat(&self) -> Option<&str> {
let a = self.attendees.iter().find(|a| a.is_owner)?;
Some(a.partstat.as_deref().unwrap_or("NEEDS-ACTION"))
}
}
pub fn is_item(c: &ICalendarComponent) -> bool {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
}
fn text(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<String> {
let v = c.property(prop)?.values.first()?.as_text()?.trim();
(!v.is_empty()).then(|| v.to_string())
}
fn person(e: &ICalendarEntry) -> Option<Person> {
let address = e.values.first()?.as_text()?.trim().to_string();
let name = e
.parameter(&ICalendarParameterName::Cn)
.and_then(|v| v.as_text())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
Some(Person { name, address })
}
/// What component `index` of `cal` says. `owner` recognises the calendar
/// owner's addresses.
pub fn event_info(cal: &ICalendar, index: usize, owner: Is) -> EventInfo {
let Some(c) = cal.components.get(index) else {
return EventInfo::default();
};
let master = cal
.components
.iter()
.find(|m| {
m.component_type == c.component_type
&& is_item(m)
&& !m.has_property(&ICalendarProperty::RecurrenceId)
})
.unwrap_or(c);
let param = |e: &ICalendarEntry, n: &ICalendarParameterName| {
e.parameter(n)
.and_then(|v| v.as_text())
.map(str::to_ascii_uppercase)
};
EventInfo {
component: c.component_type.as_str().to_string(),
summary: text(c, &ICalendarProperty::Summary),
description: text(c, &ICalendarProperty::Description),
location: text(c, &ICalendarProperty::Location),
url: text(c, &ICalendarProperty::Url),
status: text(c, &ICalendarProperty::Status).map(|s| s.to_ascii_uppercase()),
transparent: text(c, &ICalendarProperty::Transp)
.is_some_and(|t| t.eq_ignore_ascii_case("TRANSPARENT")),
all_day: c
.property(&ICalendarProperty::Dtstart)
.and_then(|e| e.values.first()?.as_partial_date_time())
.is_some_and(|d| d.hour.is_none()),
categories: c
.properties(&ICalendarProperty::Categories)
.flat_map(|e| e.values.iter().filter_map(|v| v.as_text()))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect(),
rrule: master
.property(&ICalendarProperty::Rrule)
.and_then(|e| match e.values.first()? {
ICalendarValue::RecurrenceRule(r) => Some(r.to_string()),
_ => None,
}),
organizer: c.property(&ICalendarProperty::Organizer).and_then(person),
attendees: c
.properties(&ICalendarProperty::Attendee)
.filter_map(|e| {
let p = person(e)?;
Some(Attendee {
is_owner: owner(&p.address),
partstat: param(e, &ICalendarParameterName::Partstat),
role: param(e, &ICalendarParameterName::Role),
person: p,
})
})
.collect(),
}
}
/// The component of `cal` that describes the instance at `recurrence_id`:
/// its override, else the master. Without an instance, the master.
pub fn component_for(
cal: &ICalendar,
recurrence_id: Option<DateTime<Utc>>,
floating: &Zone,
) -> Option<usize> {
let zones = Zones::new(cal, floating.clone());
let items = || {
cal.components
.iter()
.enumerate()
.filter(|(_, c)| is_item(c))
};
if let Some(rid) = recurrence_id {
let found = items().find(|(_, c)| {
c.property(&ICalendarProperty::RecurrenceId)
.and_then(|e| instant(&zones, e))
.is_some_and(|t| t == rid)
});
if let Some((i, _)) = found {
return Some(i);
}
}
items()
.find(|(_, c)| !c.has_property(&ICalendarProperty::RecurrenceId))
.or_else(|| items().next())
.map(|(i, _)| i)
}
/// A date or date-time property as a UTC instant. Floating times and dates
/// are read in the floating zone of `zones`.
pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option<DateTime<Utc>> {
let v = e.values.first()?.as_partial_date_time()?;
let dt = v.to_date_time()?;
Some(match dt.offset {
Some(o) if v.hour.is_some() => {
(dt.date_time - chrono::TimeDelta::seconds(o.local_minus_utc().into())).and_utc()
}
_ if v.hour.is_none() => zones.floating().to_utc(dt.date_time),
_ => zones.get(e.tz_id()).to_utc(dt.date_time),
})
}
/// The first calendar item of an object in brief.
#[derive(Debug, Clone, PartialEq)]
pub struct Brief {
pub summary: Option<String>,
pub start: Option<DateTime<Utc>>,
pub all_day: bool,
pub recurring: bool,
}
pub fn brief(cal: &ICalendar, floating: &Zone) -> Option<Brief> {
let i = component_for(cal, None, floating)?;
let c = &cal.components[i];
let zones = Zones::new(cal, floating.clone());
let start = c.property(&ICalendarProperty::Dtstart);
Some(Brief {
summary: text(c, &ICalendarProperty::Summary),
start: start.and_then(|e| instant(&zones, e)),
all_day: start
.and_then(|e| e.values.first()?.as_partial_date_time())
.is_some_and(|d| d.hour.is_none()),
recurring: c.has_property(&ICalendarProperty::Rrule)
|| c.has_property(&ICalendarProperty::Rdate),
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct Labeled {
pub label: Option<String>,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Card {
pub uid: Option<String>,
/// FN, else the name, the organization or the first email.
pub full_name: String,
pub org: Option<String>,
pub title: Option<String>,
pub emails: Vec<Labeled>,
pub phones: Vec<Labeled>,
pub addresses: Vec<Labeled>,
pub urls: Vec<Labeled>,
pub birthday: Option<String>,
pub anniversary: Option<String>,
pub note: Option<String>,
pub is_group: bool,
/// Member UIDs, without `urn:uuid:`.
pub members: Vec<String>,
/// An inline PHOTO. A URL photo does not count: the server never
/// fetches it.
pub has_photo: bool,
}
/// What a vCard 3 or 4 says, Apple's group and label forms included.
pub fn card(vcard: &str) -> Card {
let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect();
let prop = |l: &str| {
let n = name(l);
n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string())
};
let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string());
// Apple names a value by a sibling X-ABLABEL line, e.g.
// `_$!<Mobile>!$_` or a free text.
let label = |l: &str| {
let apple = group(l).and_then(|g| {
lines
.iter()
.find(|o| group(o).as_deref() == Some(g.as_str()) && prop(o) == "X-ABLABEL")
.map(|o| {
let v = unescape_text(value(o));
let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_");
v.to_ascii_lowercase()
})
});
apple.or_else(|| {
let types: Vec<String> = param_parts(l)
.into_iter()
.filter_map(|p| {
let (k, v) = p.split_once('=')?;
k.trim().eq_ignore_ascii_case("TYPE").then_some(v)
})
.flat_map(|v| v.trim_matches('"').split(','))
.map(|t| t.trim().to_ascii_lowercase())
.filter(|t| !["internet", "pref", "voice", "x400"].contains(&t.as_str()))
.filter(|t| !t.is_empty())
.collect();
(!types.is_empty()).then(|| types.join(", "))
})
};
let first = |p: &str| {
lines
.iter()
.find(|l| prop(l) == p)
.map(|l| unescape_text(value(l)).trim().to_string())
.filter(|v| !v.is_empty())
};
let all = |p: &str, render: &dyn Fn(&str) -> String| {
lines
.iter()
.filter(|l| prop(l) == p)
.filter_map(|l| {
let v = render(value(l));
(!v.trim().is_empty()).then(|| Labeled {
label: label(l),
value: v.trim().to_string(),
})
})
.collect::<Vec<_>>()
};
let plain = |v: &str| unescape_text(v);
let mailto = |v: &str| {
let v = unescape_text(v);
v.strip_prefix("mailto:").map(str::to_string).unwrap_or(v)
};
let tel = |v: &str| {
let v = unescape_text(v);
v.strip_prefix("tel:").map(str::to_string).unwrap_or(v)
};
let components = |v: &str| split_structured(v);
let adr = |v: &str| {
// PO box, extended, street, locality, region, code, country.
let p = components(v);
let at = |i: usize| {
p.get(i)
.map(String::as_str)
.unwrap_or("")
.trim()
.to_string()
};
let city = [at(5), at(3)]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
[at(0), at(1), at(2), city, at(4), at(6)]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n")
};
let kind = first("KIND").or_else(|| first("X-ADDRESSBOOKSERVER-KIND"));
let is_group = kind.is_some_and(|k| k.eq_ignore_ascii_case("group"));
let members = lines
.iter()
.filter(|l| {
let p = prop(l);
p == "MEMBER" || p == "X-ADDRESSBOOKSERVER-MEMBER"
})
.map(|l| {
let v = value(l).trim();
v.strip_prefix("urn:uuid:").unwrap_or(v).to_string()
})
.collect();
let has_photo = lines.iter().any(|l| {
prop(l) == "PHOTO"
&& (value(l).trim_start().starts_with("data:")
|| param(l, "ENCODING").is_some_and(|e| {
e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("base64")
}))
});
let n = first("N").map(|_| {
let raw = lines.iter().find(|l| prop(l) == "N").expect("found above");
let p = components(value(raw));
let at = |i: usize| {
p.get(i)
.map(String::as_str)
.unwrap_or("")
.trim()
.to_string()
};
[at(3), at(1), at(2), at(0), at(4)]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
});
let org = first("ORG").map(|_| {
let raw = lines
.iter()
.find(|l| prop(l) == "ORG")
.expect("found above");
components(value(raw))
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ")
});
let emails = all("EMAIL", &mailto);
let full_name = first("FN")
.or_else(|| n.clone().filter(|n| !n.is_empty()))
.or_else(|| org.clone().filter(|o| !o.is_empty()))
.or_else(|| emails.first().map(|e| e.value.clone()))
.unwrap_or_default();
Card {
uid: first("UID").map(|u| u.strip_prefix("urn:uuid:").map(str::to_string).unwrap_or(u)),
full_name,
org: org.filter(|o| !o.is_empty()),
title: first("TITLE"),
emails,
phones: all("TEL", &tel),
addresses: all("ADR", &adr),
urls: all("URL", &plain),
birthday: lines
.iter()
.find(|l| prop(l) == "BDAY")
.and_then(|l| date(value(l))),
anniversary: lines
.iter()
.find(|l| prop(l) == "ANNIVERSARY" || prop(l) == "X-ANNIVERSARY")
.and_then(|l| date(value(l))),
note: first("NOTE"),
is_group,
members,
has_photo,
}
}
/// `1980-03-15`, or `--03-15` without a year, from the date forms vCard 3
/// and 4 use.
fn date(v: &str) -> Option<String> {
let v = v.trim().split(['T', 't']).next()?;
match v.strip_prefix("--") {
Some(md) => {
let md = md.replace('-', "");
(md.len() == 4).then(|| format!("--{}-{}", &md[..2], &md[2..]))
}
None => {
let d = v.replace('-', "");
(d.len() == 8 && d.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..]))
}
}
}
/// The parts of a structured value (N, ADR, ORG): split at `;` that are not
/// escaped, then unescaped.
fn split_structured(v: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut cur = String::new();
let mut chars = v.chars();
while let Some(c) = chars.next() {
match c {
'\\' => {
cur.push(c);
if let Some(n) = chars.next() {
cur.push(n);
}
}
';' => parts.push(unescape_text(&std::mem::take(&mut cur))),
_ => cur.push(c),
}
}
parts.push(unescape_text(&cur));
parts
}
▾Mpimdav/src/zone.rs
@@ -193,7 +193,8 @@ fn agree(a: &Zone, b: &Zone) -> bool {
.all(|t| a.offset_at(t) == b.offset_at(t))
}
fn by_name(name: &str) -> Option<Zone> {
/// The zone an IANA, Windows or vendor-prefixed name stands for.
pub fn by_name(name: &str) -> Option<Zone> {
let name = name.trim();
// Vendor-prefixed IDs such as `/mozilla.org/20050126_1/Europe/Berlin`.
std::iter::once(name)
▾Mserver/src/api/mod.rs
@@ -5,8 +5,8 @@ use api_types::{
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, DAV, DAV_SHARE, EXPORT_SUFFIX, FEED, FILES,
FINISH_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, PHOTO_SUFFIX, PIM, PIM_COLLECTIONS,
PIM_SYSTEM_EXPORT, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX,
WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
PIM_CONTACTS, PIM_INSTANCES, PIM_INVITATIONS, PIM_PREVIEW, PIM_SYSTEM_EXPORT, SEARCH, SHARE,
SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -98,6 +98,7 @@ mod passkeys;
mod pim;
mod pim_api;
mod pim_schedule;
mod pim_views;
mod search;
mod shares;
mod spa;
@@ -123,6 +124,8 @@ pub fn router(state: Arc<AppState>) -> Router {
let pim_link = format!("{PIM_COLLECTIONS}/{{id}}{LINKS_SUFFIX}/{{link_id}}");
let pim_import = format!("{PIM_COLLECTIONS}/{{id}}{IMPORT_SUFFIX}");
let pim_export = format!("{PIM_COLLECTIONS}/{{id}}{EXPORT_SUFFIX}");
let pim_collection = format!("{PIM_COLLECTIONS}/{{id}}");
let pim_object = format!("{PIM_COLLECTIONS}/{{id}}{OBJECTS_SUFFIX}/{{name}}");
let pim_photo = format!("{PIM_COLLECTIONS}/{{id}}{OBJECTS_SUFFIX}/{{name}}{PHOTO_SUFFIX}");
let admin_pim_link = format!("{ADMIN_PIM_LINKS}/{{id}}");
let feed = format!("{FEED}/{{file}}");
@@ -188,7 +191,19 @@ pub fn router(state: Arc<AppState>) -> Router {
&admin_room_id,
put(admin::update_room).delete(admin::delete_room),
)
.route(PIM_COLLECTIONS, get(pim_api::list))
.route(PIM_COLLECTIONS, get(pim_api::list).post(pim_api::create))
.route(
&pim_collection,
put(pim_api::update).delete(pim_api::delete),
)
.route(&pim_object, get(pim_views::object))
.route(PIM_INSTANCES, get(pim_views::instances))
.route(PIM_CONTACTS, get(pim_views::contacts))
.route(
PIM_INVITATIONS,
get(pim_views::invitations).post(pim_views::reply),
)
.route(PIM_PREVIEW, get(pim_views::preview))
.route(&pim_shares, get(pim_api::shares).post(pim_api::share))
.route(&pim_share, delete(pim_api::unshare))
.route(&pim_links, get(pim_api::links).post(pim_api::create_link))
▾Mserver/src/api/pim.rs
@@ -65,12 +65,12 @@ const MAX_DEAD_PROPS: usize = 100;
pub(super) const MAIL_DOMAIN: &str = "filebrowser.invalid";
/// The ids of the generated collections, which no stored one has.
const DIRECTORY: i64 = 0;
const BIRTHDAYS: i64 = -1;
const DIRECTORY_SLUG: &str = "system";
const BIRTHDAYS_SLUG: &str = "birthdays";
pub(super) const DIRECTORY: i64 = 0;
pub(super) const BIRTHDAYS: i64 = -1;
pub(super) const DIRECTORY_SLUG: &str = "system";
pub(super) const BIRTHDAYS_SLUG: &str = "birthdays";
/// The slug prefix of a collection lent to the account.
const SHARED_PREFIX: &str = "shared-";
pub(super) const SHARED_PREFIX: &str = "shared-";
/// The scheduling inbox is a stored calendar collection under this slug.
pub(crate) const INBOX: &str = "inbox";
/// The scheduling outbox holds nothing and is not stored.
@@ -512,7 +512,7 @@ impl PrincipalView {
// ---------------------------------------------------------------------------
/// Whether a collection is generated rather than stored.
fn generated(id: i64) -> bool {
pub(super) fn generated(id: i64) -> bool {
id <= DIRECTORY
}
@@ -542,7 +542,7 @@ fn generated_collection(
}
}
type Members = Vec<(PimObject, Vec<u8>)>;
pub(super) type Members = Vec<(PimObject, Vec<u8>)>;
/// The generated system address book: one card per visible principal.
pub(super) async fn directory(state: &AppState) -> Result<(PimCollection, Members), ApiError> {
@@ -571,7 +571,10 @@ pub(super) async fn directory(state: &AppState) -> Result<(PimCollection, Member
/// anniversaries in its own address books, not lent ones.
// ponytail: rebuilt from every contact on each request. Store the events if
// large address books make it slow.
async fn birthdays(state: &AppState, principal: i64) -> Result<(PimCollection, Members), ApiError> {
pub(super) async fn birthdays(
state: &AppState,
principal: i64,
) -> Result<(PimCollection, Members), ApiError> {
let mut members = Vec::new();
for book in state
.db
@@ -594,6 +597,20 @@ async fn birthdays(state: &AppState, principal: i64) -> Result<(PimCollection, M
Ok((col, members))
}
/// The members of collection `id`, stored or generated. `principal` owns
/// a generated birthday calendar.
pub(super) async fn members_of(
state: &AppState,
principal: i64,
id: i64,
) -> Result<Members, ApiError> {
match id {
DIRECTORY => Ok(directory(state).await?.1),
BIRTHDAYS => Ok(birthdays(state, principal).await?.1),
id => Ok(state.db.pim_objects_with_data(id).await?),
}
}
fn generated_object(name: String, uid: String, component: &str, data: &[u8]) -> PimObject {
PimObject {
name,
@@ -710,11 +727,7 @@ impl Cx<'_> {
}
async fn members(&self, c: &PimCollection) -> Result<Members, ApiError> {
match c.id {
DIRECTORY => Ok(directory(self.state).await?.1),
BIRTHDAYS => Ok(birthdays(self.state, self.space().id).await?.1),
id => Ok(self.state.db.pim_objects_with_data(id).await?),
}
members_of(self.state, self.space().id, c.id).await
}
async fn member(
@@ -730,6 +743,44 @@ impl Cx<'_> {
}
}
/// Deletes a collection of principal `owner`. A calendar's scheduling
/// objects are cancelled for their attendees first. `Err` names the
/// precondition that refuses it: the calendar that receives invitations
/// stays.
pub(super) async fn delete_own(
state: &AppState,
owner: i64,
kind: PimKind,
col: &PimCollection,
) -> Result<Result<(), Element>, ApiError> {
let db = &state.db;
if kind == PimKind::Calendar && col.slug != INBOX {
if db
.pim_calendar_for(owner, "VEVENT")
.await?
.is_some_and(|d| d.id == col.id)
{
return Ok(Err(el(CALDAV, "default-calendar-needed")));
}
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(state).await?;
let owner = dir
.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);
}
}
db.pim_apply(&ops).await?;
}
db.pim_delete_collection(col.id).await?;
Ok(Ok(()))
}
/// A collection lent to the signed-in account, as it appears in their home.
fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col {
let name = c.displayname.take().unwrap_or_else(|| c.slug.clone());
@@ -1895,36 +1946,10 @@ impl Cx<'_> {
Access::Own if *kind == PimKind::Calendar && col.slug == INBOX => {
denied(&space.home(*kind), "unbind")
}
Access::Own => {
if scheduling
&& db
.pim_calendar_for(space.id, "VEVENT")
.await?
.is_some_and(|d| d.id == col.id)
{
return Ok(error(
StatusCode::FORBIDDEN,
el(CALDAV, "default-calendar-needed"),
));
}
if scheduling {
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(self.state).await?;
let owner = self.owner(&col, &dir).await?;
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(self.state, &dir, &w, &data, true).await?
{
ops.extend(more);
}
}
db.pim_apply(&ops).await?;
}
db.pim_delete_collection(col.id).await?;
status(StatusCode::NO_CONTENT)
}
Access::Own => match delete_own(self.state, space.id, *kind, &col).await? {
Ok(()) => status(StatusCode::NO_CONTENT),
Err(condition) => error(StatusCode::FORBIDDEN, condition),
},
// Deleting a lent collection only takes it out of this home.
_ if slug.starts_with(SHARED_PREFIX) && space.mine => {
db.pim_remove_share(col.id, self.me.id).await?;
▾Mserver/src/api/pim_api.rs
@@ -1,5 +1,6 @@
//! JSON management of calendars and address books (session-authenticated):
//! - `GET {PIM_COLLECTIONS}` — own and lent collections
//! - `GET`, `POST {PIM_COLLECTIONS}` — own, lent and generated; a new one
//! - `PUT`, `DELETE {PIM_COLLECTIONS}/{id}` — change or delete one, or end its loan
//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection
//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
@@ -17,8 +18,9 @@ use std::collections::HashMap;
use std::sync::Arc;
use api_types::{
CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo, PimCollectionKind,
PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode, PimSkipped,
CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo,
PimCollectionKind, PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode,
PimSkipped, UpdatePimCollection,
};
use axum::Json;
use axum::body::Body;
@@ -33,10 +35,13 @@ use sha2::{Digest, Sha256};
use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
use crate::api::dav::challenge;
use crate::api::files::{disposition, find_root, require_rw_root};
use crate::api::pim::{INBOX, collection_href, etag_of};
use crate::api::pim::{
BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, OUTBOX, SHARED_PREFIX,
collection_href, delete_own, etag_of, generated, members_of,
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::auth;
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp};
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace};
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -57,6 +62,37 @@ fn name_of(c: &PimCollection) -> String {
c.displayname.clone().unwrap_or_else(|| c.slug.clone())
}
/// A collection as `GET {PIM_COLLECTIONS}` lists it.
fn info(
c: &PimCollection,
kind: PimKind,
url: String,
owner: &str,
mode: Option<PimShareMode>,
) -> PimCollectionInfo {
PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
name: name_of(c),
url,
owner: owner.to_string(),
mode,
generated: generated(c.id),
color: c.color.clone(),
description: c.description.clone(),
components: c
.components
.split(',')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
transparent: c.transparent,
is_default: false,
shares: 0,
links: 0,
}
}
/// GET {PIM_COLLECTIONS}
pub async fn list(
State(state): State<Arc<AppState>>,
@@ -65,35 +101,233 @@ pub async fn list(
let me = &auth.user;
let pid = state.db.principal_of(me.id).await?;
state.db.pim_ensure_defaults(pid).await?;
let default = state
.db
.pim_calendar_for(pid, "VEVENT")
.await?
.map(|c| c.id);
let mut out = Vec::new();
for kind in [PimKind::Calendar, PimKind::AddressBook] {
for c in state.db.pim_collections(pid, kind).await? {
if kind == PimKind::Calendar && c.slug == INBOX {
continue;
}
let url = collection_href(&me.name, kind, &c.slug, None);
out.push(PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
name: name_of(&c),
url: collection_href(&me.name, kind, &c.slug, None),
owner: me.name.clone(),
mode: None,
is_default: default == Some(c.id),
shares: state.db.pim_shares(c.id).await?.len(),
links: state.db.pim_links(c.id).await?.len(),
..info(&c, kind, url, &me.name, None)
});
}
let (slug, generated) = match kind {
PimKind::Calendar => (BIRTHDAYS_SLUG, generated_info(BIRTHDAYS)),
PimKind::AddressBook => (DIRECTORY_SLUG, generated_info(DIRECTORY)),
};
let url = collection_href(&me.name, kind, slug, None);
out.push(info(&generated, kind, url, &me.name, None));
for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? {
out.push(PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
name: name_of(&c),
url: collection_href(&me.name, kind, &c.slug, Some(c.id)),
owner,
mode: Some(mode),
});
let url = collection_href(&me.name, kind, &c.slug, Some(c.id));
out.push(info(&c, kind, url, &owner, Some(mode)));
}
}
Ok(Json(out))
}
/// A generated collection without its members, which listing it needs
/// not build.
fn generated_info(id: i64) -> PimCollection {
match id {
BIRTHDAYS => PimCollection {
id,
slug: BIRTHDAYS_SLUG.to_string(),
displayname: Some("Birthdays".to_string()),
components: "VEVENT".to_string(),
transparent: true,
..Default::default()
},
_ => PimCollection {
id,
slug: DIRECTORY_SLUG.to_string(),
displayname: Some("Directory".to_string()),
..Default::default()
},
}
}
fn db_kind(kind: PimCollectionKind) -> PimKind {
match kind {
PimCollectionKind::Calendar => PimKind::Calendar,
PimCollectionKind::Addressbook => PimKind::AddressBook,
}
}
/// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`.
fn valid_color(c: &str) -> bool {
c.strip_prefix('#')
.is_some_and(|h| [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()))
}
fn bad_request(msg: &str) -> ApiError {
ApiError::new(StatusCode::BAD_REQUEST, msg)
}
/// A URL segment from a display name: ASCII letters, digits and dashes.
fn slug_of(name: &str, kind: PimKind) -> String {
let mut slug = String::new();
for c in name.chars().flat_map(char::to_lowercase) {
match c {
'a'..='z' | '0'..='9' => slug.push(c),
_ if !slug.ends_with('-') && !slug.is_empty() => slug.push('-'),
_ => {}
}
}
let slug: String = slug.trim_end_matches('-').chars().take(40).collect();
match slug.trim_end_matches('-') {
"" => match kind {
PimKind::Calendar => "calendar".to_string(),
PimKind::AddressBook => "contacts".to_string(),
},
s => s.to_string(),
}
}
/// POST {PIM_COLLECTIONS}
pub async fn create(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Json(body): Json<CreatePimCollection>,
) -> Result<Json<PimCollectionInfo>, ApiError> {
let me = &auth.user;
let pid = state.db.principal_of(me.id).await?;
let kind = db_kind(body.kind);
let name = body.name.trim();
if name.is_empty() {
return Err(bad_request("a name is required"));
}
let color = body.color.filter(|c| !c.trim().is_empty());
if color.as_deref().is_some_and(|c| !valid_color(c)) {
return Err(bad_request("invalid color"));
}
let components = match kind {
PimKind::Calendar if body.components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::Calendar => {
let comps: Vec<String> = body
.components
.iter()
.map(|c| c.trim().to_ascii_uppercase())
.collect();
if !comps
.iter()
.all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()))
{
return Err(bad_request("unknown component type"));
}
comps.join(",")
}
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)
};
let mut col = PimCollection {
displayname: Some(name.to_string()),
description: body.description.filter(|d| !d.trim().is_empty()),
color,
components,
..Default::default()
};
for n in 1..100 {
let slug = match n {
1 if !reserved(&base) => base.clone(),
1 => continue,
n => format!("{base}-{n}"),
};
col.slug = slug.clone();
if state.db.pim_create_collection(pid, kind, &col, &[]).await? {
let c = state
.db
.pim_collection(pid, kind, &slug)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let url = collection_href(&me.name, kind, &slug, None);
return Ok(Json(info(&c, kind, url, &me.name, None)));
}
}
Err(ApiError::new(StatusCode::CONFLICT, "no free name"))
}
/// PUT {PIM_COLLECTIONS}/{id}
pub async fn update(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdatePimCollection>,
) -> Result<Json<PimCollectionInfo>, ApiError> {
let id = own(&state, &auth, id).await?;
let (_, kind, mut col) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
if let Some(name) = body.name {
let name = name.trim();
if name.is_empty() {
return Err(bad_request("a name is required"));
}
col.displayname = Some(name.to_string());
}
if let Some(color) = body.color {
let color = color.trim();
if !color.is_empty() && !valid_color(color) {
return Err(bad_request("invalid color"));
}
col.color = (!color.is_empty()).then(|| color.to_string());
}
if let Some(d) = body.description {
col.description = (!d.trim().is_empty()).then(|| d.trim().to_string());
}
if let Some(t) = body.transparent {
if kind != PimKind::Calendar {
return Err(bad_request("transparent needs a calendar"));
}
col.transparent = t;
}
state
.db
.pim_patch(PropPlace::Collection(id), Some(&col), &[], &[])
.await?;
let url = collection_href(&auth.user.name, kind, &col.slug, None);
Ok(Json(info(&col, kind, url, &auth.user.name, None)))
}
/// DELETE {PIM_COLLECTIONS}/{id}: an own collection, or the loan of a lent
/// one.
pub async fn delete(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
let pid = state.db.principal_of(auth.user.id).await?;
if generated(id) {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
if owner != pid {
state.db.pim_remove_share(id, auth.user.id).await?;
return Ok(Json(OkResp {}));
}
match delete_own(&state, pid, kind, &col).await? {
Ok(()) => Ok(Json(OkResp {})),
Err(_) => Err(ApiError::localized(
StatusCode::CONFLICT,
"the calendar that receives invitations cannot be deleted",
"err_default_calendar",
)),
}
}
/// The id of a collection the signed-in user owns, or 404.
async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
let pid = state.db.principal_of(auth.user.id).await?;
@@ -170,13 +404,21 @@ pub async fn unshare(
/// A collection the signed-in user may read: its owner principal, kind, the
/// collection, and whether they may also write it. The inbox is not one.
async fn reachable(
pub(super) async fn reachable(
state: &AppState,
auth: &SessionUser,
id: i64,
) -> Result<(i64, PimKind, PimCollection, bool), ApiError> {
let not_found = || ApiError::new(StatusCode::NOT_FOUND, "collection not found");
let pid = state.db.principal_of(auth.user.id).await?;
if generated(id) {
let (kind, col) = match id {
BIRTHDAYS => (PimKind::Calendar, generated_info(BIRTHDAYS)),
DIRECTORY => (PimKind::AddressBook, generated_info(DIRECTORY)),
_ => return Err(not_found()),
};
return Ok((pid, kind, col, false));
}
let (owner, kind, c) = state
.db
.pim_collection_by_id(id)
@@ -366,7 +608,7 @@ pub async fn feed(
return Ok(challenge());
}
}
let Some((_, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
let Some((owner, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let etag = format!(
@@ -390,7 +632,7 @@ pub async fn feed(
true => Detail::Busy,
false => Detail::Public,
};
let body = render(&state, kind, &col, detail).await?;
let body = render(&state, owner, kind, &col, detail).await?;
Ok((
[
(CONTENT_TYPE, mime(kind).to_string()),
@@ -411,11 +653,12 @@ fn mime(kind: PimKind) -> &'static str {
async fn render(
state: &AppState,
owner: i64,
kind: PimKind,
col: &PimCollection,
detail: Detail,
) -> Result<String, ApiError> {
let objects = state.db.pim_objects_with_data(col.id).await?;
let objects = members_of(state, owner, col.id).await?;
let texts: Vec<String> = objects
.into_iter()
.map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
@@ -433,8 +676,8 @@ pub async fn export(
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Response, ApiError> {
let (_, kind, col, _) = reachable(&state, &auth, id).await?;
let body = render(&state, kind, &col, Detail::All).await?;
let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
let body = render(&state, owner, kind, &col, Detail::All).await?;
Ok(download(kind, &name_of(&col), body))
}
@@ -445,8 +688,8 @@ pub async fn export_to_root(
AxumPath(id): AxumPath<i64>,
Json(target): Json<PimRootFile>,
) -> Result<Json<OkResp>, ApiError> {
let (_, kind, col, _) = reachable(&state, &auth, id).await?;
let body = render(&state, kind, &col, Detail::All).await?;
let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
let body = render(&state, owner, kind, &col, Detail::All).await?;
save(&state, &auth, target, body).await
}
@@ -653,7 +896,7 @@ pub async fn import(
Ok(Json(result))
}
async fn read_root_file(
pub(super) async fn read_root_file(
state: &AppState,
auth: &SessionUser,
file: PimRootFile,
▾Aserver/src/api/pim_views.rs
@@ -0,0 +1,619 @@
//! What the web UI shows of calendars and address books (session-authenticated):
//! - `GET {PIM_INSTANCES}` — occurrences in a range
//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}` — one event or contact
//! - `GET {PIM_CONTACTS}` — contacts, searched
//! - `GET {PIM_INVITATIONS}`, `POST` a reply — unanswered invitations
//! - `GET {PIM_PREVIEW}` — what an `.ics`/`.vcf` file holds
//!
//! The UI never parses iCalendar or vCard: these endpoints do.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use api_types::{
OBJECTS_SUFFIX, OkResp, PHOTO_SUFFIX, PIM_COLLECTIONS, PimAttendee, PimCollectionKind,
PimContact, PimContactDetail, PimEventDetail, PimInstance, PimInstances, PimInvitation,
PimLabeled, PimObjectDetail, PimPerson, PimPreview, PimPreviewContact, PimPreviewEvent,
PimReply, PimRootFile, PimShareMode,
};
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::expand::expand;
use pimdav::itip::{self, Role};
use pimdav::principal::UserType;
use pimdav::view::{self, Card, EventInfo, Person};
use pimdav::zone::{self, Zone};
use pimdav::{bundle, object};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::api::common::SessionUser;
use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, etag_of, mailto, members_of, seg};
use crate::api::pim_api::{reachable, read_root_file};
use crate::api::pim_schedule::{self, Directory, Writer};
use crate::db::{PimKind, PimObject, PimOp};
use crate::error::{ApiError, AppState};
/// The widest range `GET {PIM_INSTANCES}` expands.
const MAX_RANGE_DAYS: i64 = 400;
/// The most instances one answer holds.
const MAX_INSTANCES: usize = 5000;
/// The most entries a preview lists.
const MAX_PREVIEW: usize = 200;
/// How far ahead an invitation's next instance is looked for.
const INVITATION_HORIZON_DAYS: i64 = 3653;
fn rfc3339(t: DateTime<Utc>) -> String {
t.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn parse_time(s: &str) -> Result<DateTime<Utc>, ApiError> {
DateTime::parse_from_rfc3339(s)
.map(|t| t.with_timezone(&Utc))
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "times must be RFC 3339"))
}
/// The zone all-day and floating times are read in: the viewer's.
fn floating(tz: Option<&str>) -> Zone {
tz.and_then(zone::by_name).unwrap_or(Zone::Utc)
}
fn wanted(ids: Option<&str>) -> Option<HashSet<i64>> {
ids.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
}
/// `(collection id, owner principal)` of the calendars or address books the
/// signed-in user reads: own ones, the generated one and lent ones. Not the
/// scheduling inbox.
async fn readable(
state: &AppState,
auth: &SessionUser,
kind: PimKind,
) -> Result<Vec<(i64, i64)>, ApiError> {
let db = &state.db;
let pid = db.principal_of(auth.user.id).await?;
db.pim_ensure_defaults(pid).await?;
let mut out: Vec<(i64, i64)> = db
.pim_collections(pid, kind)
.await?
.into_iter()
.filter(|c| c.slug != INBOX)
.map(|c| (c.id, pid))
.collect();
out.push(match kind {
PimKind::Calendar => (BIRTHDAYS, pid),
PimKind::AddressBook => (DIRECTORY, pid),
});
for (col, _, _) in db.pim_shared_collections(auth.user.id, kind).await? {
if let Some((owner, _, _)) = db.pim_collection_by_id(col.id).await? {
out.push((col.id, owner));
}
}
Ok(out)
}
fn parse(data: &[u8]) -> Option<ICalendar> {
ICalendar::parse(String::from_utf8_lossy(data).as_ref()).ok()
}
fn display(p: &Person) -> String {
p.name.clone().unwrap_or_else(|| {
p.address
.strip_prefix("mailto:")
.unwrap_or(&p.address)
.to_string()
})
}
fn wire_person(p: &Person) -> PimPerson {
PimPerson {
name: p.name.clone(),
address: p.address.clone(),
}
}
#[derive(Deserialize)]
pub struct InstancesQuery {
from: String,
to: String,
tz: Option<String>,
collections: Option<String>,
}
/// GET {PIM_INSTANCES}
// ponytail: parses and expands every object of every calendar on each call.
// Index each object's first and last instance if large calendars get slow.
pub async fn instances(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Query(q): Query<InstancesQuery>,
) -> Result<Json<PimInstances>, ApiError> {
let (from, to) = (parse_time(&q.from)?, parse_time(&q.to)?);
if to <= from || to - from > TimeDelta::days(MAX_RANGE_DAYS) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"the range must be positive and at most 400 days",
));
}
let zone = floating(q.tz.as_deref());
let wanted = wanted(q.collections.as_deref());
let dir = Directory::load(&state).await?;
let mut out = Vec::new();
let mut truncated = false;
'all: for (id, owner) in readable(&state, &auth, PimKind::Calendar).await? {
if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
continue;
}
let owns = dir.is(owner);
for (obj, data) in members_of(&state, owner, id).await? {
let Some(cal) = parse(&data) else {
continue;
};
let exp = expand(&cal, from..to, zone.clone());
truncated |= exp.truncated;
let mut infos: HashMap<usize, EventInfo> = HashMap::new();
for i in exp.instances {
if out.len() == MAX_INSTANCES {
truncated = true;
break 'all;
}
let info = infos
.entry(i.component)
.or_insert_with(|| view::event_info(&cal, i.component, &owns));
out.push(PimInstance {
collection_id: id,
name: obj.name.clone(),
uid: obj.uid.clone(),
recurrence_id: i.recurrence_id.map(rfc3339),
start: rfc3339(i.start),
end: rfc3339(i.end),
all_day: info.all_day,
component: info.component.clone(),
summary: info.summary.clone(),
location: info.location.clone(),
status: info.status.clone(),
transparent: info.transparent,
has_attendees: !info.attendees.is_empty(),
partstat: info.partstat().map(str::to_string),
organizer: info.organizer.as_ref().map(display),
});
}
}
}
out.sort_by(|a, b| (&a.start, &a.end).cmp(&(&b.start, &b.end)));
Ok(Json(PimInstances {
instances: out,
truncated,
}))
}
#[derive(Deserialize)]
pub struct DetailQuery {
recurrence_id: Option<String>,
tz: Option<String>,
}
/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}
pub async fn object(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath((id, name)): AxumPath<(i64, String)>,
Query(q): Query<DetailQuery>,
) -> Result<Json<PimObjectDetail>, ApiError> {
let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
let members = members_of(&state, owner, col.id).await?;
let (obj, data) = members
.iter()
.find(|(o, _)| o.name == name)
.ok_or_else(not_found)?;
match kind {
PimKind::Calendar => {
let cal = parse(data).ok_or_else(not_found)?;
let zone = floating(q.tz.as_deref());
let rid = q.recurrence_id.as_deref().map(parse_time).transpose()?;
let index = view::component_for(&cal, rid, &zone).ok_or_else(not_found)?;
let dir = Directory::load(&state).await?;
let owns = dir.is(owner);
let info = view::event_info(&cal, index, &owns);
let answers = may_answer(&state, &auth, owner, col.id).await?;
let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee));
Ok(Json(PimObjectDetail::Event(PimEventDetail {
collection_id: id,
name: obj.name.clone(),
uid: obj.uid.clone(),
component: info.component,
summary: info.summary,
description: info.description,
location: info.location,
url: info.url,
status: info.status,
transparent: info.transparent,
all_day: info.all_day,
categories: info.categories,
rrule: info.rrule,
organizer: info.organizer.as_ref().map(wire_person),
attendees: info
.attendees
.iter()
.map(|a| PimAttendee {
person: wire_person(&a.person),
partstat: a.partstat.clone(),
role: a.role.clone(),
is_owner: a.is_owner,
})
.collect(),
can_edit: writable,
can_reply: attendee && answers,
})))
}
PimKind::AddressBook => {
let card = view::card(&String::from_utf8_lossy(data));
let members = match card.is_group {
true => {
let by_uid: HashMap<String, String> = members
.iter()
.map(|(_, d)| view::card(&String::from_utf8_lossy(d)))
.filter_map(|c| Some((c.uid?, c.full_name)))
.collect();
card.members
.iter()
.map(|m| by_uid.get(m).cloned().unwrap_or_else(|| m.clone()))
.collect()
}
false => Vec::new(),
};
let photo_url = card.has_photo.then(|| {
format!(
"{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
seg(&obj.name)
)
});
Ok(Json(PimObjectDetail::Contact(contact_detail(
id, obj, card, members, photo_url, writable,
))))
}
}
}
fn labeled(v: Vec<view::Labeled>) -> Vec<PimLabeled> {
v.into_iter()
.map(|l| PimLabeled {
label: l.label,
value: l.value,
})
.collect()
}
fn contact_detail(
id: i64,
obj: &PimObject,
card: Card,
members: Vec<String>,
photo_url: Option<String>,
can_edit: bool,
) -> PimContactDetail {
PimContactDetail {
collection_id: id,
name: obj.name.clone(),
uid: card.uid,
full_name: card.full_name,
org: card.org,
title: card.title,
emails: labeled(card.emails),
phones: labeled(card.phones),
addresses: labeled(card.addresses),
urls: labeled(card.urls),
birthday: card.birthday,
anniversary: card.anniversary,
note: card.note,
is_group: card.is_group,
members,
photo_url,
can_edit,
}
}
/// Whether the signed-in user may answer invitations in a calendar of
/// `owner`: their own, or one lent with `rw+schedule`.
async fn may_answer(
state: &AppState,
auth: &SessionUser,
owner: i64,
collection_id: i64,
) -> Result<bool, ApiError> {
if collection_id <= DIRECTORY {
return Ok(false);
}
if owner == state.db.principal_of(auth.user.id).await? {
return Ok(true);
}
Ok(state
.db
.pim_shared_collection(auth.user.id, PimKind::Calendar, collection_id)
.await?
.is_some_and(|(_, _, mode)| mode == PimShareMode::RwSchedule))
}
#[derive(Deserialize)]
pub struct ContactsQuery {
q: Option<String>,
collections: Option<String>,
}
/// GET {PIM_CONTACTS}
pub async fn contacts(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Query(q): Query<ContactsQuery>,
) -> Result<Json<Vec<PimContact>>, ApiError> {
let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase();
let wanted = wanted(q.collections.as_deref());
let mut out = Vec::new();
for (id, owner) in readable(&state, &auth, PimKind::AddressBook).await? {
if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
continue;
}
for (obj, data) in members_of(&state, owner, id).await? {
let c = view::card(&String::from_utf8_lossy(&data));
let hit = needle.is_empty()
|| [Some(&c.full_name), c.org.as_ref()]
.into_iter()
.flatten()
.chain(c.emails.iter().map(|e| &e.value))
.chain(c.phones.iter().map(|p| &p.value))
.any(|v| v.to_lowercase().contains(&needle));
if !hit {
continue;
}
out.push(PimContact {
collection_id: id,
name: obj.name,
full_name: c.full_name,
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,
is_group: c.is_group,
});
}
}
out.sort_by_cached_key(|c| (c.full_name.to_lowercase(), c.collection_id));
Ok(Json(out))
}
#[derive(Deserialize)]
pub struct TzQuery {
tz: Option<String>,
}
/// GET {PIM_INVITATIONS}: in the signed-in user's own calendars, the series
/// and instances they are invited to and have not answered, and whose next
/// instance is still ahead.
pub async fn invitations(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Query(q): Query<TzQuery>,
) -> Result<Json<Vec<PimInvitation>>, ApiError> {
let db = &state.db;
let pid = db.principal_of(auth.user.id).await?;
let dir = Directory::load(&state).await?;
let owns = dir.is(pid);
let zone = floating(q.tz.as_deref());
let now = Utc::now();
let window = now..now + TimeDelta::days(INVITATION_HORIZON_DAYS);
let mut out = Vec::new();
for col in db.pim_collections(pid, PimKind::Calendar).await? {
if col.slug == INBOX {
continue;
}
for (obj, data) in db.pim_objects_with_data(col.id).await? {
// Most objects invite no one: skip their parse.
if !data.windows(8).any(|w| w.eq_ignore_ascii_case(b"ATTENDEE")) {
continue;
}
let Some(cal) = parse(&data) else {
continue;
};
if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
continue;
}
let instances = expand(&cal, window.clone(), zone.clone()).instances;
for (index, c) in cal.components.iter().enumerate() {
if !view::is_item(c) {
continue;
}
let info = view::event_info(&cal, index, &owns);
if info.partstat() != Some("NEEDS-ACTION")
|| info.status.as_deref() == Some("CANCELLED")
{
continue;
}
let Some(next) = instances.iter().find(|i| i.component == index) else {
continue;
};
let is_override = c.has_property(&ICalendarProperty::RecurrenceId);
out.push(PimInvitation {
collection_id: col.id,
name: obj.name.clone(),
uid: obj.uid.clone(),
recurrence_id: is_override
.then_some(next.recurrence_id)
.flatten()
.map(rfc3339),
summary: info.summary.clone(),
location: info.location.clone(),
organizer: info.organizer.as_ref().map(wire_person),
start: rfc3339(next.start),
end: rfc3339(next.end),
all_day: info.all_day,
recurring: info.rrule.is_some() && !is_override,
});
}
}
}
out.sort_by(|a, b| a.start.cmp(&b.start));
Ok(Json(out))
}
/// POST {PIM_INVITATIONS}: writes the answer into the calendar owner's copy
/// through the same path as a client's PUT, so the organizer gets the REPLY.
pub async fn reply(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Json(body): Json<PimReply>,
) -> Result<Json<OkResp>, ApiError> {
let answer = match body.partstat.to_ascii_uppercase().as_str() {
"ACCEPTED" => ICalendarParticipationStatus::Accepted,
"TENTATIVE" => ICalendarParticipationStatus::Tentative,
"DECLINED" => ICalendarParticipationStatus::Declined,
_ => {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"partstat must be ACCEPTED, TENTATIVE or DECLINED",
));
}
};
let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?;
let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?;
if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? {
return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here"));
}
let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
let _lock = pim_schedule::LOCK.lock().await;
let (obj, old) = state
.db
.pim_object(col.id, &body.name)
.await?
.ok_or_else(not_found)?;
let cal = parse(&old).ok_or_else(not_found)?;
let dir = Directory::load(&state).await?;
let principal = dir.get(owner).cloned().ok_or_else(not_found)?;
let owns = dir.is(owner);
if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"the calendar owner is not an attendee",
));
}
let zone = floating(body.tz.as_deref());
let new = itip::respond(&cal, &owns, answer, rid, &zone).to_string();
let me = state.db.principal_of(auth.user.id).await?;
let w = Writer {
owner: &principal,
may_schedule: true,
sent_by: (me != owner)
.then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
};
let stored = match pim_schedule::put(
&state,
&dir,
&w,
(col.id, &obj.name),
Some(&old),
new.as_bytes(),
)
.await?
{
Ok(s) => s,
Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)),
};
let mut ops = vec![PimOp::Put {
collection_id: col.id,
obj: PimObject {
etag: etag_of(&stored.data),
schedule_tag: stored.schedule_tag.clone(),
..obj
},
data: stored.data,
}];
ops.extend(stored.ops);
state.db.pim_apply(&ops).await?;
Ok(Json(OkResp {}))
}
#[derive(Deserialize)]
pub struct PreviewQuery {
root: i64,
path: String,
tz: Option<String>,
}
/// GET {PIM_PREVIEW}
pub async fn preview(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Query(q): Query<PreviewQuery>,
) -> Result<Json<PimPreview>, ApiError> {
let data = read_root_file(
&state,
&auth,
PimRootFile {
root_id: q.root,
path: q.path,
},
)
.await?;
let text = String::from_utf8(data)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect());
let upper = text.to_ascii_uppercase();
let mut new_uid = |t: &str| crate::hex(&Sha256::digest(t))[..32].to_string();
let zone = floating(q.tz.as_deref());
let mut out = PimPreview {
kind: PimCollectionKind::Calendar,
total: 0,
invalid: 0,
events: Vec::new(),
contacts: Vec::new(),
};
if upper.contains("BEGIN:VCALENDAR") {
let parts = bundle::split_calendar(&text, &mut new_uid);
out.total = parts.len();
for part in parts {
let brief = object::calendar(part.as_bytes(), &["VEVENT", "VTODO", "VJOURNAL"])
.ok()
.and_then(|_| parse(part.as_bytes()))
.and_then(|cal| view::brief(&cal, &zone));
match brief {
Some(b) => {
if out.events.len() < MAX_PREVIEW {
out.events.push(PimPreviewEvent {
summary: b.summary,
start: b.start.map(rfc3339),
all_day: b.all_day,
recurring: b.recurring,
});
}
}
None => out.invalid += 1,
}
}
} else if upper.contains("BEGIN:VCARD") {
out.kind = PimCollectionKind::Addressbook;
let parts = bundle::split_cards(&text, &mut new_uid);
out.total = parts.len();
for part in parts {
if object::vcard(part.as_bytes()).is_err() {
out.invalid += 1;
continue;
}
if out.contacts.len() < MAX_PREVIEW {
let c = view::card(&part);
out.contacts.push(PimPreviewContact {
full_name: c.full_name,
email: c.emails.into_iter().next().map(|e| e.value),
phone: c.phones.into_iter().next().map(|p| p.value),
});
}
}
} else {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"the file holds no calendar or address objects",
));
}
Ok(Json(out))
}
▾Aserver/tests/api_pim_ui.rs
@@ -0,0 +1,629 @@
//! The JSON endpoints behind the calendar and contacts web UI.
mod common;
use axum::http::{Method, StatusCode};
use common::*;
use serde_json::{Value, json};
const PW: &str = "secret12345";
struct Ui {
env: Env,
alice: Client,
bob: Client,
}
impl Ui {
async fn new() -> Self {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "alice", PW, &[("docs", "rw"), ("src", "ro")]).await;
create_user(&admin, "bob", PW, &[]).await;
let alice = login(&env, "alice", PW).await;
let bob = login(&env, "bob", PW).await;
Ui { env, alice, bob }
}
async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&[("authorization", &basic(user, PW)), ("depth", "1")],
body.as_bytes().to_vec(),
)
.await
}
async fn put(&self, user: &str, path: &str, body: &str) {
let r = self.dav(user, "PUT", path, body).await;
assert!(
r.status.is_success(),
"PUT {path}: {} {}",
r.status,
r.text()
);
}
async fn list(&self, who: &Client) -> Vec<Value> {
let r = who.get("/api/pim/collections").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json().as_array().unwrap().clone()
}
async fn id(&self, who: &Client, url: &str) -> i64 {
self.list(who)
.await
.iter()
.find(|c| c["url"] == url)
.unwrap_or_else(|| panic!("no {url}"))["id"]
.as_i64()
.unwrap()
}
async fn root_id(&self, index: usize) -> i64 {
self.alice.get("/api/auth/me").await.json()["roots"][index]["id"]
.as_i64()
.unwrap()
}
}
fn ics(body: &str) -> String {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n{body}END:VCALENDAR\r\n")
}
fn event(uid: &str, props: &str) -> String {
ics(&format!(
"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n{props}END:VEVENT\r\n"
))
}
const ALICE: &str = "mailto:alice@filebrowser.invalid";
const BOB: &str = "mailto:bob@filebrowser.invalid";
#[tokio::test]
async fn collections_can_be_created_changed_and_deleted() {
let ui = Ui::new().await;
let list = ui.list(&ui.alice).await;
// Default calendar and address book, plus the generated ones.
let generated: Vec<i64> = list
.iter()
.filter(|c| c["generated"] == true)
.map(|c| c["id"].as_i64().unwrap())
.collect();
assert_eq!(generated, [-1, 0], "{list:?}");
let default = list.iter().find(|c| c["is_default"] == true).unwrap();
assert_eq!(default["kind"], "calendar");
let r = ui
.alice
.post_json(
"/api/pim/collections",
&json!({"kind": "calendar", "name": "Work & Play", "color": "#ff8800", "components": ["VEVENT"]}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let work = r.json();
assert_eq!(work["url"], "/pim/calendars/alice/work-play/");
assert_eq!(work["components"], json!(["VEVENT"]));
let id = work["id"].as_i64().unwrap();
// A second one with the same name gets its own URL.
let again = ui
.alice
.post_json(
"/api/pim/collections",
&json!({"kind": "calendar", "name": "Work & Play"}),
)
.await;
assert_eq!(again.json()["url"], "/pim/calendars/alice/work-play-2/");
// Reserved names and bad input.
let r = ui
.alice
.post_json(
"/api/pim/collections",
&json!({"kind": "addressbook", "name": "System"}),
)
.await;
assert_eq!(r.json()["url"], "/pim/addressbooks/alice/system-2/");
for bad in [
json!({"kind": "calendar", "name": " "}),
json!({"kind": "calendar", "name": "x", "color": "red"}),
json!({"kind": "calendar", "name": "x", "components": ["VCARD"]}),
] {
let r = ui.alice.post_json("/api/pim/collections", &bad).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad}");
}
let r = ui
.alice
.put_json(
&format!("/api/pim/collections/{id}"),
&json!({"name": "Work", "color": "", "description": "Job", "transparent": true}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let changed = r.json();
assert_eq!(changed["name"], "Work");
assert_eq!(changed["color"], Value::Null);
assert_eq!(changed["description"], "Job");
assert_eq!(changed["transparent"], true);
// CalDAV sees the same.
let r = ui
.dav("alice", "PROPFIND", "/pim/calendars/alice/work-play/", "")
.await;
assert!(r.text().contains("Work</"), "{}", r.text());
// Bob cannot touch it, even when it is lent to him.
let r = ui
.alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": "bob", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
let lent = ui
.list(&ui.bob)
.await
.into_iter()
.find(|c| c["id"] == id)
.unwrap();
assert_eq!(lent["mode"], "rw");
assert_eq!(lent["owner"], "alice");
assert_eq!(
ui.list(&ui.alice)
.await
.iter()
.find(|c| c["id"] == id)
.unwrap()["shares"],
1
);
let r = ui
.bob
.put_json(&format!("/api/pim/collections/{id}"), &json!({"name": "x"}))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Bob's delete only ends his loan.
assert_eq!(
ui.bob
.delete(&format!("/api/pim/collections/{id}"))
.await
.status,
StatusCode::OK
);
assert!(ui.list(&ui.bob).await.iter().all(|c| c["id"] != id));
assert!(ui.list(&ui.alice).await.iter().any(|c| c["id"] == id));
// Generated and default collections stay.
for gone in [-1, 0] {
let r = ui
.alice
.delete(&format!("/api/pim/collections/{gone}"))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
let default_id = default["id"].as_i64().unwrap();
let r = ui
.alice
.delete(&format!("/api/pim/collections/{default_id}"))
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(r.json()["code"], "err_default_calendar");
let r = ui.alice.delete(&format!("/api/pim/collections/{id}")).await;
assert_eq!(r.status, StatusCode::OK);
assert!(ui.list(&ui.alice).await.iter().all(|c| c["id"] != id));
}
#[tokio::test]
async fn instances_expand_readable_calendars() {
let ui = Ui::new().await;
ui.put(
"alice",
"/pim/calendars/alice/default/weekly.ics",
&(event(
"weekly",
"DTSTART;TZID=Europe/Berlin:20260105T090000\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=3\r\nSUMMARY:Standup\r\nLOCATION:Room 1\r\n",
)
.replace(
"END:VCALENDAR",
"BEGIN:VEVENT\r\nUID:weekly\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;TZID=Europe/Berlin:20260112T090000\r\nDTSTART;TZID=Europe/Berlin:20260112T110000\r\nDURATION:PT1H\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nEND:VCALENDAR",
)),
)
.await;
ui.put(
"alice",
"/pim/calendars/alice/default/day.ics",
&event("day", "DTSTART;VALUE=DATE:20260110\r\nSUMMARY:Trip\r\n"),
)
.await;
ui.put(
"alice",
"/pim/addressbooks/alice/default/anna.vcf",
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:anna\r\nFN:Anna\r\nBDAY:1980-01-07\r\nEND:VCARD\r\n",
)
.await;
let get = |q: &str| {
let q = q.to_string();
let alice = ui.alice.clone();
async move { alice.get(&format!("/api/pim/instances?{q}")).await }
};
let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&tz=Europe/Berlin").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let all = r.json();
let list = all["instances"].as_array().unwrap();
let summaries: Vec<&str> = list
.iter()
.map(|i| i["summary"].as_str().unwrap())
.collect();
assert_eq!(
summaries,
["Standup", "🎂 Anna (1980)", "Trip", "Moved", "Standup"],
"{all}"
);
assert_eq!(list[0]["start"], "2026-01-05T08:00:00Z");
assert_eq!(list[0]["location"], "Room 1");
assert_eq!(list[0]["recurrence_id"], "2026-01-05T08:00:00Z");
// All-day values start at midnight in the viewer's zone.
assert_eq!(list[2]["start"], "2026-01-09T23:00:00Z");
assert_eq!(list[2]["all_day"], true);
assert_eq!(list[2]["recurrence_id"], Value::Null);
assert_eq!(list[3]["recurrence_id"], "2026-01-12T08:00:00Z");
assert_eq!(list[1]["collection_id"], -1);
assert_eq!(all["truncated"], false);
// Only the chosen calendars.
let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&collections=-1").await;
assert_eq!(r.json()["instances"].as_array().unwrap().len(), 1);
// Bad ranges.
for q in [
"from=2026-01-01T00:00:00Z&to=2027-06-01T00:00:00Z",
"from=2026-02-01T00:00:00Z&to=2026-01-01T00:00:00Z",
"from=yesterday&to=2026-01-01T00:00:00Z",
] {
assert_eq!(get(q).await.status, StatusCode::BAD_REQUEST, "{q}");
}
// A read-only loan shows to the borrower, not otherwise.
let q = "/api/pim/instances?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z";
let count = |v: Value| {
v["instances"]
.as_array()
.unwrap()
.iter()
.filter(|i| i["uid"] == "weekly")
.count()
};
assert_eq!(count(ui.bob.get(q).await.json()), 0);
let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
ui.alice
.post_json(
&format!("/api/pim/collections/{cal}/shares"),
&json!({"user": "bob", "mode": "ro"}),
)
.await;
assert_eq!(count(ui.bob.get(q).await.json()), 3);
}
#[tokio::test]
async fn object_detail_for_events_and_contacts() {
let ui = Ui::new().await;
ui.put(
"alice",
"/pim/calendars/alice/default/m.ics",
&event(
"m",
&format!(
"DTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=2\r\nSUMMARY:Plan\r\nDESCRIPTION:Line 1\\nLine 2\r\nCATEGORIES:Work,Q1\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT:{BOB}\r\n"
),
),
)
.await;
let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
let r = ui
.alice
.get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let d = r.json();
assert_eq!(d["type"], "event");
assert_eq!(d["description"], "Line 1\nLine 2");
assert_eq!(d["categories"], json!(["Work", "Q1"]));
assert_eq!(d["rrule"], "FREQ=DAILY;COUNT=2");
assert_eq!(d["organizer"]["name"], "Alice");
assert_eq!(d["attendees"][1]["role"], "OPT-PARTICIPANT");
assert_eq!(d["attendees"][0]["is_owner"], true);
assert_eq!(d["can_edit"], true);
assert_eq!(d["can_reply"], false);
// Bob's copy: he may answer.
let bob = ui
.bob
.get("/api/pim/instances?from=2026-01-05T00:00:00Z&to=2026-01-06T00:00:00Z")
.await
.json();
let copy = &bob["instances"][0];
assert_eq!(copy["partstat"], "NEEDS-ACTION", "{bob}");
let d = ui
.bob
.get(&format!(
"/api/pim/collections/{}/objects/{}",
copy["collection_id"],
copy["name"].as_str().unwrap()
))
.await
.json();
assert_eq!(d["can_reply"], true, "{d}");
// Someone else's object stays hidden.
let r = ui
.bob
.get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// A contact with a photo, and a group.
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
ui.put(
"alice",
"/pim/addressbooks/alice/default/c1.vcf",
&format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Carla Díaz\r\nN:Díaz;Carla;;;\r\nORG:Acme;Sales\r\nitem1.EMAIL;TYPE=INTERNET:carla@example.com\r\nitem1.X-ABLABEL:_$!<Other>!$_\r\nTEL;TYPE=CELL:+49 1\r\nADR;TYPE=WORK:;;Main St 1;Berlin;;10115;Germany\r\nBDAY:--03-15\r\nPHOTO;ENCODING=b;TYPE=PNG:{png}\r\nEND:VCARD\r\n"
),
)
.await;
ui.put(
"alice",
"/pim/addressbooks/alice/default/g.vcf",
"BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nMEMBER:urn:uuid:gone\r\nEND:VCARD\r\n",
)
.await;
let book = ui.id(&ui.alice, "/pim/addressbooks/alice/default/").await;
let d = ui
.alice
.get(&format!("/api/pim/collections/{book}/objects/c1.vcf"))
.await
.json();
assert_eq!(d["type"], "contact");
assert_eq!(d["org"], "Acme, Sales");
assert_eq!(
d["emails"][0],
json!({"label": "other", "value": "carla@example.com"})
);
assert_eq!(d["phones"][0]["label"], "cell");
assert_eq!(
d["addresses"][0]["value"],
"Main St 1\n10115 Berlin\nGermany"
);
assert_eq!(d["birthday"], "--03-15");
let photo = d["photo_url"].as_str().unwrap().to_string();
assert_eq!(ui.alice.get(&photo).await.status, StatusCode::OK);
let g = ui
.alice
.get(&format!("/api/pim/collections/{book}/objects/g.vcf"))
.await
.json();
assert_eq!(g["is_group"], true);
assert_eq!(g["members"], json!(["Carla Díaz", "gone"]));
// The generated birthday calendar has details too.
let list = ui
.alice
.get("/api/pim/instances?from=2026-03-01T00:00:00Z&to=2026-04-01T00:00:00Z")
.await
.json();
let bday = &list["instances"][0];
let d = ui
.alice
.get(&format!(
"/api/pim/collections/-1/objects/{}",
bday["name"].as_str().unwrap()
))
.await
.json();
assert_eq!(d["summary"], "🎂 Carla Díaz");
assert_eq!(d["can_edit"], false);
}
#[tokio::test]
async fn contacts_are_searched_across_books() {
let ui = Ui::new().await;
ui.put(
"alice",
"/pim/addressbooks/alice/default/z.vcf",
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:Zoë Weiß\r\nEMAIL:zoe@example.com\r\nEND:VCARD\r\n",
)
.await;
ui.put(
"alice",
"/pim/addressbooks/alice/default/n.vcf",
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nN:Nobody;Nora;;;\r\nTEL:+1 555\r\nEND:VCARD\r\n",
)
.await;
let all = ui.alice.get("/api/pim/contacts").await.json();
let names: Vec<&str> = all
.as_array()
.unwrap()
.iter()
.map(|c| c["full_name"].as_str().unwrap())
.collect();
// The directory lists the accounts too.
assert_eq!(names, ["admin", "alice", "bob", "Nora Nobody", "Zoë Weiß"]);
let hits = ui.alice.get("/api/pim/contacts?q=ZOË").await.json();
assert_eq!(hits.as_array().unwrap().len(), 1);
assert_eq!(hits[0]["email"], "zoe@example.com");
let hits = ui.alice.get("/api/pim/contacts?q=555").await.json();
assert_eq!(hits[0]["full_name"], "Nora Nobody");
let own = ui.alice.get("/api/pim/contacts?collections=0").await.json();
assert_eq!(own.as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn invitations_are_listed_and_answered() {
let ui = Ui::new().await;
// A month ahead, so the invitation stays open whenever the test runs.
let day = chrono::Utc::now().date_naive() + chrono::Days::new(30);
let (first, second) = (
day.format("%Y%m%d"),
(day + chrono::Days::new(1)).format("%Y%m%d"),
);
let meeting = event(
"meet",
&format!(
"DTSTART:{first}T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Sync\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;RSVP=TRUE:{BOB}\r\n"
),
);
ui.put("alice", "/pim/calendars/alice/default/meet.ics", &meeting)
.await;
let list = ui.bob.get("/api/pim/invitations").await.json();
let list = list.as_array().unwrap();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(list[0]["summary"], "Sync");
assert_eq!(list[0]["recurring"], true);
assert_eq!(list[0]["recurrence_id"], Value::Null);
assert_eq!(
list[0]["start"],
format!("{}T09:00:00Z", day.format("%Y-%m-%d"))
);
assert!(
ui.alice
.get("/api/pim/invitations")
.await
.json()
.as_array()
.unwrap()
.is_empty()
);
// One instance first: an override in bob's copy, a REPLY for alice.
let reply = |partstat: &str, rid: Option<&str>| {
let mut body = json!({
"collection_id": list[0]["collection_id"],
"name": list[0]["name"],
"partstat": partstat,
});
if let Some(rid) = rid {
body["recurrence_id"] = json!(rid);
}
body
};
let r = ui
.bob
.post_json(
"/api/pim/invitations",
&reply(
"DECLINED",
Some(&format!(
"{}T09:00:00Z",
(day + chrono::Days::new(1)).format("%Y-%m-%d")
)),
),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let org = ui
.dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "")
.await
.text()
.replace("\r\n ", "");
assert!(
org.contains(&format!("RECURRENCE-ID:{second}T090000Z")),
"{org}"
);
assert!(org.contains("PARTSTAT=DECLINED"), "{org}");
// The series is still open.
assert_eq!(
ui.bob
.get("/api/pim/invitations")
.await
.json()
.as_array()
.unwrap()
.len(),
1
);
let r = ui
.bob
.post_json("/api/pim/invitations", &reply("ACCEPTED", None))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(
ui.bob
.get("/api/pim/invitations")
.await
.json()
.as_array()
.unwrap()
.is_empty()
);
let org = ui
.dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "")
.await
.text()
.replace("\r\n ", "");
let bob_line = org
.lines()
.find(|l| l.starts_with("ATTENDEE") && l.contains(BOB) && !l.contains("DECLINED"))
.unwrap_or_else(|| panic!("{org}"));
assert!(bob_line.contains("PARTSTAT=ACCEPTED"), "{org}");
// Bad answers.
let r = ui
.bob
.post_json("/api/pim/invitations", &reply("MAYBE", None))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = ui
.alice
.post_json("/api/pim/invitations", &reply("ACCEPTED", None))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn files_are_previewed() {
let ui = Ui::new().await;
let file = ics(
"BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260105T090000Z\r\nSUMMARY:One\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:b\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;VALUE=DATE:20260106\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:Two\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:No start\r\nEND:VEVENT\r\n",
);
std::fs::write(ui.env.file("docs/cal.ics"), file).unwrap();
std::fs::write(
ui.env.file("docs/people.vcf"),
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ann\r\nEMAIL:ann@example.com\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ben\r\nEND:VCARD\r\n",
)
.unwrap();
std::fs::write(ui.env.file("docs/notes.txt"), "hello").unwrap();
let root = ui.root_id(0).await;
let get = |path: &str| {
let url = format!("/api/pim/preview?root={root}&path={path}&tz=Europe/Berlin");
let alice = ui.alice.clone();
async move { alice.get(&url).await }
};
let r = get("cal.ics").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let p = r.json();
assert_eq!(p["kind"], "calendar");
assert_eq!(p["total"], 3);
assert_eq!(p["invalid"], 0, "{p}");
assert_eq!(p["events"][2]["start"], Value::Null);
assert_eq!(
p["events"][0],
json!({"summary": "One", "start": "2026-01-05T09:00:00Z", "all_day": false, "recurring": false})
);
assert_eq!(p["events"][1]["start"], "2026-01-05T23:00:00Z");
assert_eq!(p["events"][1]["recurring"], true);
let p = get("people.vcf").await.json();
assert_eq!(p["kind"], "addressbook");
assert_eq!(p["contacts"][0]["email"], "ann@example.com");
assert_eq!(p["contacts"][1]["full_name"], "Ben");
assert_eq!(get("notes.txt").await.status, StatusCode::BAD_REQUEST);
assert_eq!(get("missing.ics").await.status, StatusCode::NOT_FOUND);
// Another account's root is out of reach.
let r = ui
.bob
.get(&format!("/api/pim/preview?root={root}&path=cal.ics"))
.await;
assert!(r.status.is_client_error(), "{}", r.status);
}
▾Mweb/app.css
@@ -2857,3 +2857,366 @@ body:has(.page-busy) {
.auth-card .btn {
width: 100%;
}
/* ------------------------------------------------------------------ */
/* Calendars and contacts */
/* ------------------------------------------------------------------ */
.pim-view {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
width: 100%;
}
.pim-side {
flex: none;
width: 260px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.pim-side-head {
display: flex;
align-items: center;
gap: 4px;
}
.pim-side-head .admin-section-title {
flex: 1;
margin: 0;
}
.pim-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
background: var(--panel);
}
.pim-group-label {
padding: 10px 10px 4px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--border);
}
.pim-row {
display: flex;
align-items: center;
min-width: 0;
}
.pim-row:hover {
background: var(--bg);
}
.pim-toggle {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.pim-swatch {
display: inline-block;
flex: none;
width: 14px;
height: 14px;
background: var(--swatch);
border: 2px solid var(--swatch);
}
/* A hidden collection keeps its color as an outline only. */
.pim-row-hidden .pim-swatch {
background: transparent;
}
.pim-row-hidden .pim-row-name {
color: var(--muted);
}
.pim-row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
justify-content: flex-start;
gap: 6px;
padding: 8px 10px 8px 0;
border: 0;
background: none;
color: var(--text);
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.pim-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pim-row-note {
flex: none;
max-width: 45%;
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pim-main {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
background: var(--panel);
}
.pim-modal {
width: 600px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
.pim-modal-title {
display: flex;
align-items: center;
gap: 10px;
overflow-wrap: anywhere;
}
.pim-note {
margin: -6px 0 8px;
font-size: 13px;
}
.pim-section {
display: block;
padding: 14px 0 0;
margin-top: 14px;
border-top: 1px solid var(--border);
}
.pim-section-title {
margin: 0 0 8px;
font-size: 14px;
}
.pim-hint {
margin: 0 0 10px;
font-size: 13px;
}
.pim-section-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.pim-section-actions.pim-left {
justify-content: flex-start;
}
.pim-colors {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.pim-color {
width: 26px;
height: 26px;
padding: 0;
border: 2px solid var(--panel);
outline: 1px solid var(--border);
background: var(--swatch);
cursor: pointer;
}
.pim-color.selected {
outline: 2px solid var(--text);
}
.pim-color-input {
width: 34px;
height: 30px;
padding: 0 2px;
cursor: pointer;
}
.pim-items {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.pim-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.pim-item-name {
flex: 1;
min-width: 0;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pim-mode {
flex: none;
width: auto;
max-width: 55%;
padding: 6px 28px 6px 8px;
}
.pim-add {
display: flex;
gap: 8px;
align-items: center;
}
.pim-add .field-input {
flex: 1;
min-width: 0;
}
.pim-link {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
min-width: 0;
}
.pim-link-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.pim-link-url {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pim-link-meta {
font-size: 12px;
}
.pim-link-actions {
display: flex;
flex: none;
gap: 6px;
}
.pim-link-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
}
.pim-inline {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--muted);
}
.pim-inline input,
.pim-inline select {
width: auto;
min-width: 140px;
}
.pim-hints,
.pim-sample {
margin: 0 0 8px;
padding-left: 18px;
font-size: 13px;
line-height: 1.5;
}
.pim-sample li {
overflow-wrap: anywhere;
}
.admin-stack {
display: flex;
flex-direction: column;
gap: 28px;
}
.rooms-hint {
margin-bottom: 12px;
}
.room-address {
font-size: 12px;
margin-top: 2px;
overflow-wrap: anywhere;
}
.feeds-header {
margin-top: 28px;
}
@media (max-width: 860px) {
.pim-view {
flex-direction: column;
}
.pim-side {
width: auto;
}
.pim-main {
min-height: 240px;
}
}
@media (max-width: 640px) {
.pim-link {
flex-direction: column;
align-items: stretch;
}
.pim-link-actions {
flex-wrap: wrap;
}
.pim-add {
flex-wrap: wrap;
}
.pim-mode {
max-width: 100%;
}
}
▾Mweb/src/api.rs
@@ -23,6 +23,15 @@ use api_types::{
PasskeyRegisterFinish, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings,
SortKey, UnlockShare, UpdateUser,
};
use api_types::{
ADMIN_PIM_LINKS, ADMIN_ROOMS, CreatePimCollection, CreatePimLink, CreatePimShare, CreateRoom,
EXPORT_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, P_TZ, PIM_COLLECTIONS, PIM_PREVIEW, PimRootFile,
SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
};
pub use api_types::{
AdminPimLink, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview,
PimShareInfo, PimShareMode, RoomInfo, RoomKind,
};
pub use api_types::{
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
Mode, NewAppPassword, OkResp, Op, PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo,
@@ -1162,6 +1171,207 @@ async fn read_entries(
}
}
// ---------------------------------------------------------------------------
// Calendars and address books
// ---------------------------------------------------------------------------
fn enc(s: &str) -> String {
js_sys::encode_uri_component(s).into()
}
pub fn pim_collections()
-> impl std::future::Future<Output = Result<Vec<PimCollectionInfo>, ApiError>> {
request("GET", PIM_COLLECTIONS.to_string(), None::<()>)
}
pub fn pim_create_collection(
body: CreatePimCollection,
) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
request("POST", PIM_COLLECTIONS.to_string(), Some(body))
}
pub fn pim_update_collection(
id: i64,
body: UpdatePimCollection,
) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
request("PUT", format!("{PIM_COLLECTIONS}/{id}"), Some(body))
}
/// Deletes an own collection, or ends the loan of a lent one.
pub fn pim_delete_collection(
id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{PIM_COLLECTIONS}/{id}"), None::<()>)
}
pub fn pim_shares(
id: i64,
) -> impl std::future::Future<Output = Result<Vec<PimShareInfo>, ApiError>> {
request(
"GET",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
None::<()>,
)
}
/// Lends a collection, or changes the mode of a loan.
pub fn pim_share(
id: i64,
user: &str,
mode: PimShareMode,
) -> impl std::future::Future<Output = Result<PimShareInfo, ApiError>> {
request(
"POST",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
Some(CreatePimShare {
user: user.to_string(),
mode,
}),
)
}
pub fn pim_unshare(
id: i64,
user_id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"DELETE",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}"),
None::<()>,
)
}
pub fn pim_links(id: i64) -> impl std::future::Future<Output = Result<Vec<PimLinkInfo>, ApiError>> {
request(
"GET",
format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
None::<()>,
)
}
pub fn pim_create_link(
id: i64,
body: CreatePimLink,
) -> impl std::future::Future<Output = Result<PimLinkInfo, ApiError>> {
request(
"POST",
format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
Some(body),
)
}
pub fn pim_delete_link(
id: i64,
link_id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"DELETE",
format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}"),
None::<()>,
)
}
/// Imports a file of a root into a collection.
pub fn pim_import(
id: i64,
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<PimImportResult, ApiError>> {
request(
"POST",
format!("{PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}"),
Some(PimRootFile {
root_id,
path: path.to_string(),
}),
)
}
/// Downloads a collection as one `.ics` or `.vcf` file.
pub fn pim_export_url(id: i64) -> String {
format!("{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}")
}
/// Saves a collection as a new file into a root.
pub fn pim_export_to_root(
id: i64,
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
pim_export_url(id),
Some(PimRootFile {
root_id,
path: path.to_string(),
}),
)
}
pub fn pim_preview(
root_id: i64,
path: &str,
tz: &str,
) -> impl std::future::Future<Output = Result<PimPreview, ApiError>> {
request(
"GET",
format!(
"{PIM_PREVIEW}?{P_ROOT}={root_id}&{P_PATH}={}&{P_TZ}={}",
enc(path),
enc(tz)
),
None::<()>,
)
}
pub fn list_rooms() -> impl std::future::Future<Output = Result<Vec<RoomInfo>, ApiError>> {
request("GET", ADMIN_ROOMS.to_string(), None::<()>)
}
pub fn create_room(
name: &str,
display_name: &str,
kind: RoomKind,
) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
request(
"POST",
ADMIN_ROOMS.to_string(),
Some(CreateRoom {
name: name.to_string(),
display_name: Some(display_name.to_string()).filter(|d| !d.is_empty()),
kind,
}),
)
}
pub fn update_room(
id: i64,
display_name: &str,
) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
request(
"PUT",
format!("{ADMIN_ROOMS}/{id}"),
Some(UpdateRoom {
display_name: display_name.to_string(),
}),
)
}
pub fn delete_room(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_ROOMS}/{id}"), None::<()>)
}
/// Admin only: every public calendar and address book feed.
pub fn admin_pim_links() -> impl std::future::Future<Output = Result<Vec<AdminPimLink>, ApiError>> {
request("GET", ADMIN_PIM_LINKS.to_string(), None::<()>)
}
pub fn admin_delete_pim_link(
id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_PIM_LINKS}/{id}"), None::<()>)
}
// ---------------------------------------------------------------------------
// Low-level request helpers
// ---------------------------------------------------------------------------
▾Mweb/src/i18n.rs
@@ -261,6 +261,7 @@ i18n_keys! {
ERR_BAD_UPLOAD = "err_bad_upload" => "invalid upload data",
ERR_BAD_UPLOAD_PATH = "err_bad_upload_path" => "invalid file path in upload",
ERR_CHALLENGE_EXPIRED = "err_challenge_expired" => "That took too long, please try again.",
ERR_DEFAULT_CALENDAR = "err_default_calendar" => "The calendar that receives invitations cannot be deleted.",
ERR_DST_REQUIRED = "err_dst_required" => "dst_root_id is required",
ERR_FILE_NAME_REQUIRED = "err_file_name_required" => "a file name is required",
ERR_FILES_EXIST = "err_files_exist" => "some files already exist",
@@ -430,6 +431,101 @@ i18n_keys! {
PASSWORD_MIN_HINT = "password_min_hint" => "at least 8 characters",
PASSWORD_REMOVED = "password_removed" => "Password removed",
PASSWORD_RESETS_SIGNIN = "password_resets_signin" => "Setting a password also deletes this account's passkeys and drops any two-factor requirement.",
PIM_ACCESS = "pim_access" => "Access",
PIM_ADD = "pim_add" => "Add",
PIM_ADDRESS = "pim_address" => "Address for apps",
PIM_ADDRESS_BOOKS = "pim_address_books" => "Address books",
PIM_APP_PASSWORDS = "pim_app_passwords" => "Create an app password",
PIM_AUTO = "pim_auto" => "automatic",
PIM_BIRTHDAYS = "pim_birthdays" => "Birthdays",
PIM_CAL_HINT = "pim_cal_hint" => "Pick a calendar on the left to manage it, or connect a calendar app.",
PIM_CALENDAR = "pim_calendar" => "Calendar",
PIM_CALENDARS = "pim_calendars" => "Calendars",
PIM_CLIENTS = "pim_clients" => "Apps",
PIM_COLLECTION_URLS = "pim_collection_urls" => "Addresses of single calendars and address books",
PIM_COLOR = "pim_color" => "Color",
PIM_CONNECT = "pim_connect" => "Connect an app",
PIM_CONNECT_TITLE = "pim_connect_title" => "Connect a calendar or contacts app",
PIM_CONTACTS = "pim_contacts" => "Contacts",
PIM_CONTACTS_HINT = "pim_contacts_hint" => "Pick an address book on the left to manage it, or connect a contacts app.",
PIM_CREATED = "pim_created" => "Created",
PIM_CUSTOM_COLOR = "pim_custom_color" => "Custom color",
PIM_DEFAULT_HINT = "pim_default_hint" => "This calendar receives invitations. It cannot be deleted.",
PIM_DELETE_MSG = "pim_delete_msg" => "Everything in it is deleted. The attendees of your meetings get a cancellation.",
PIM_DELETE_TITLE = "pim_delete_title" => "Delete “{}”?",
PIM_DESCRIPTION = "pim_description" => "Description",
PIM_DETAIL = "pim_detail" => "Details",
PIM_DETAIL_BUSY = "pim_detail_busy" => "Busy times only",
PIM_DETAIL_FULL = "pim_detail_full" => "All details",
PIM_DIRECTORY = "pim_directory" => "Directory",
PIM_EXPORT = "pim_export" => "Export",
PIM_EXPORTED = "pim_exported" => "Saved as {}",
PIM_FEEDS = "pim_feeds" => "Calendar and contact feeds",
PIM_FEEDS_HINT = "pim_feeds_hint" => "Every public calendar and address book link on this server.",
PIM_FILE_CONTACTS = "pim_file_contacts" => "{} contacts",
PIM_FILE_EVENTS = "pim_file_events" => "{} entries",
PIM_FILE_INVALID = "pim_file_invalid" => "{} not valid",
PIM_GENERAL = "pim_general" => "General",
PIM_GENERATED_HINT = "pim_generated_hint" => "The server fills this in. It is read-only.",
PIM_HIDE = "pim_hide" => "Hide",
PIM_HINT_APPLE = "pim_hint_apple" => "Apple: add a CalDAV and a CardDAV account with the server address. Apple needs HTTPS.",
PIM_HINT_DAVX5 = "pim_hint_davx5" => "Android: in DAVx⁵, sign in with URL and user name, and use the server address.",
PIM_HINT_GNOME = "pim_hint_gnome" => "GNOME and Evolution: add a CalDAV or WebDAV account with the server address.",
PIM_HINT_THUNDERBIRD = "pim_hint_thunderbird" => "Thunderbird: add a network calendar with the server address. Set its email identity to “None”, or it sends no invitations.",
PIM_IMPORT_BTN = "pim_import_btn" => "Import",
PIM_IMPORT_DONE = "pim_import_done" => "{} new, {} updated.",
PIM_IMPORT_INTO = "pim_import_into" => "Import into",
PIM_IMPORT_MENU = "pim_import_menu" => "Import into calendar or contacts…",
PIM_IMPORT_SKIPPED = "pim_import_skipped" => "{} skipped:",
PIM_IMPORT_TITLE = "pim_import_title" => "Import “{}”",
PIM_IMPORTING = "pim_importing" => "Importing…",
PIM_KIND_RESOURCE = "pim_kind_resource" => "Resource",
PIM_KIND_ROOM = "pim_kind_room" => "Room",
PIM_LINK_BUSY = "pim_link_busy" => "busy times only",
PIM_LINK_FULL = "pim_link_full" => "all details",
PIM_LINK_REVOKED = "pim_link_revoked" => "Link revoked",
PIM_LINKS = "pim_links" => "Public links",
PIM_LINKS_HINT = "pim_links_hint" => "Anyone with the link can subscribe. Google Calendar and Outlook.com need a link without a password.",
PIM_LINKS_HINT_CARDS = "pim_links_hint_cards" => "Anyone with the link can download the contacts once. Apps do not follow changes.",
PIM_MODE_RO = "pim_mode_ro" => "Read only",
PIM_MODE_RW = "pim_mode_rw" => "Read and write",
PIM_MODE_SCHEDULE = "pim_mode_schedule" => "Read, write and send invitations",
PIM_NAV = "pim_nav" => "Calendar & contacts",
PIM_NEW_ADDRESS_BOOK = "pim_new_address_book" => "New address book",
PIM_NEW_CALENDAR = "pim_new_calendar" => "New calendar",
PIM_NEW_ROOM = "pim_new_room" => "New room",
PIM_NO_FEEDS = "pim_no_feeds" => "No public feeds.",
PIM_NO_LINKS = "pim_no_links" => "No public links.",
PIM_NO_ROOMS = "pim_no_rooms" => "No rooms or resources.",
PIM_NO_SHARES = "pim_no_shares" => "Not shared with anyone.",
PIM_NO_TARGET = "pim_no_target" => "You have no writable calendar or address book for this file.",
PIM_NO_WRITABLE_FOLDER = "pim_no_writable_folder" => "You have no writable folder.",
PIM_PASSWORD_HINT = "pim_password_hint" => "Use an app password rather than your account password.",
PIM_REMOVE_FROM_LIST = "pim_remove_from_list" => "Remove from my list",
PIM_REMOVE_MSG = "pim_remove_msg" => "You no longer see it. The owner can share it again.",
PIM_REMOVED = "pim_removed" => "Removed from your list",
PIM_ROOM_DELETE_CONFIRM = "pim_room_delete_confirm" => "Delete “{}”? Its bookings are lost.",
PIM_ROOM_DISPLAY = "pim_room_display" => "Display name",
PIM_ROOM_NAME = "pim_room_name" => "Name (part of its address, cannot change)",
PIM_ROOMS = "pim_rooms" => "Rooms and resources",
PIM_ROOMS_HINT = "pim_rooms_hint" => "Rooms and resources can be invited to meetings. They accept free times and decline busy ones on their own.",
PIM_SAVE_ERR = "pim_save_err" => "Could not save: {}",
PIM_SAVE_HERE = "pim_save_here" => "Save here",
PIM_SAVE_TO_FOLDER = "pim_save_to_folder" => "Save to folder…",
PIM_SERVER_ADDRESS = "pim_server_address" => "Server address",
PIM_SERVER_ADDRESS_HINT = "pim_server_address_hint" => "If an app does not find the server, use this address:",
PIM_SHARED_BY = "pim_shared_by" => "Shared by {}",
PIM_SHARED_OK = "pim_shared_ok" => "Shared with {}",
PIM_SHARED_WITH_ME = "pim_shared_with_me" => "Shared with me",
PIM_SHARING = "pim_sharing" => "Sharing",
PIM_SHARING_HINT = "pim_sharing_hint" => "Share with another account on this server.",
PIM_SHOW = "pim_show" => "Show",
PIM_SUBSCRIBE = "pim_subscribe" => "Subscribe",
PIM_TRANSPARENT = "pim_transparent" => "Show as free: its events do not block time in invitations",
PIM_UNSHARE = "pim_unshare" => "Stop sharing",
PIM_UNSHARED = "pim_unshared" => "No longer shared",
PIM_USER = "pim_user" => "User name",
PIM_USER_NAME = "pim_user_name" => "User name",
PROFILE = "profile" => "Profile",
PROFILE_SAVE_ERR = "profile_save_err" => "Could not save profile: {}",
PROFILE_SAVED = "profile_saved" => "Profile saved",
@@ -717,6 +813,10 @@ const DE: &[(&str, &str)] = &[
k::ERR_CHALLENGE_EXPIRED,
"Das hat zu lange gedauert. Bitte erneut versuchen.",
),
(
k::ERR_DEFAULT_CALENDAR,
"Der Kalender, der Einladungen empfängt, kann nicht gelöscht werden.",
),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
(k::ERR_FILE_NAME_REQUIRED, "ein Dateiname ist erforderlich"),
(k::ERR_FILES_EXIST, "einige Dateien existieren bereits"),
@@ -1030,6 +1130,170 @@ const DE: &[(&str, &str)] = &[
"password_resets_signin",
"Ein neues Passwort löscht auch die Passkeys dieses Kontos und hebt eine Zwei-Faktor-Pflicht auf.",
),
("pim_access", "Zugriff"),
("pim_add", "Hinzufügen"),
("pim_address", "Adresse für Apps"),
("pim_address_books", "Adressbücher"),
("pim_app_passwords", "App-Passwort erstellen"),
("pim_auto", "automatisch"),
("pim_birthdays", "Geburtstage"),
(
"pim_cal_hint",
"Wählen Sie links einen Kalender, um ihn zu verwalten, oder verbinden Sie eine Kalender-App.",
),
("pim_calendar", "Kalender"),
("pim_calendars", "Kalender"),
("pim_clients", "Apps"),
(
"pim_collection_urls",
"Adressen einzelner Kalender und Adressbücher",
),
("pim_color", "Farbe"),
("pim_connect", "App verbinden"),
("pim_connect_title", "Kalender- oder Kontakte-App verbinden"),
("pim_contacts", "Kontakte"),
(
"pim_contacts_hint",
"Wählen Sie links ein Adressbuch, um es zu verwalten, oder verbinden Sie eine Kontakte-App.",
),
("pim_created", "Erstellt"),
("pim_custom_color", "Eigene Farbe"),
(
"pim_default_hint",
"Dieser Kalender empfängt Einladungen. Er kann nicht gelöscht werden.",
),
(
"pim_delete_msg",
"Alles darin wird gelöscht. Die Teilnehmer Ihrer Termine erhalten eine Absage.",
),
("pim_delete_title", "„{}“ löschen?"),
("pim_description", "Beschreibung"),
("pim_detail", "Details"),
("pim_detail_busy", "Nur belegte Zeiten"),
("pim_detail_full", "Alle Details"),
("pim_directory", "Verzeichnis"),
("pim_export", "Exportieren"),
("pim_exported", "Gespeichert als {}"),
("pim_feeds", "Kalender- und Kontakt-Feeds"),
(
"pim_feeds_hint",
"Jeder öffentliche Kalender- und Adressbuch-Link auf diesem Server.",
),
("pim_file_contacts", "{} Kontakte"),
("pim_file_events", "{} Einträge"),
("pim_file_invalid", "{} ungültig"),
("pim_general", "Allgemein"),
(
"pim_generated_hint",
"Der Server füllt dies aus. Es ist schreibgeschützt.",
),
("pim_hide", "Ausblenden"),
(
"pim_hint_apple",
"Apple: Fügen Sie ein CalDAV- und ein CardDAV-Konto mit der Serveradresse hinzu. Apple braucht HTTPS.",
),
(
"pim_hint_davx5",
"Android: Melden Sie sich in DAVx⁵ mit URL und Benutzername an und nutzen Sie die Serveradresse.",
),
(
"pim_hint_gnome",
"GNOME und Evolution: Fügen Sie ein CalDAV- oder WebDAV-Konto mit der Serveradresse hinzu.",
),
(
"pim_hint_thunderbird",
"Thunderbird: Fügen Sie einen Netzwerkkalender mit der Serveradresse hinzu. Setzen Sie seine E-Mail-Identität auf „Keine“, sonst verschickt er keine Einladungen.",
),
("pim_import_btn", "Importieren"),
("pim_import_done", "{} neu, {} aktualisiert."),
("pim_import_into", "Importieren in"),
("pim_import_menu", "In Kalender oder Kontakte importieren…"),
("pim_import_skipped", "{} übersprungen:"),
("pim_import_title", "„{}“ importieren"),
("pim_importing", "Importiere…"),
("pim_kind_resource", "Ressource"),
("pim_kind_room", "Raum"),
("pim_link_busy", "nur belegte Zeiten"),
("pim_link_full", "alle Details"),
("pim_link_revoked", "Link widerrufen"),
("pim_links", "Öffentliche Links"),
(
"pim_links_hint",
"Jeder mit dem Link kann ihn abonnieren. Google Kalender und Outlook.com brauchen einen Link ohne Passwort.",
),
(
"pim_links_hint_cards",
"Jeder mit dem Link kann die Kontakte einmal herunterladen. Apps folgen keinen Änderungen.",
),
("pim_mode_ro", "Nur lesen"),
("pim_mode_rw", "Lesen und schreiben"),
(
"pim_mode_schedule",
"Lesen, schreiben und Einladungen senden",
),
("pim_nav", "Kalender & Kontakte"),
("pim_new_address_book", "Neues Adressbuch"),
("pim_new_calendar", "Neuer Kalender"),
("pim_new_room", "Neuer Raum"),
("pim_no_feeds", "Keine öffentlichen Feeds."),
("pim_no_links", "Keine öffentlichen Links."),
("pim_no_rooms", "Keine Räume oder Ressourcen."),
("pim_no_shares", "Mit niemandem geteilt."),
(
"pim_no_target",
"Sie haben keinen beschreibbaren Kalender und kein beschreibbares Adressbuch für diese Datei.",
),
(
"pim_no_writable_folder",
"Sie haben keinen beschreibbaren Ordner.",
),
(
"pim_password_hint",
"Nutzen Sie ein App-Passwort statt Ihres Kontopassworts.",
),
("pim_remove_from_list", "Aus meiner Liste entfernen"),
(
"pim_remove_msg",
"Sie sehen ihn nicht mehr. Der Eigentümer kann ihn erneut teilen.",
),
("pim_removed", "Aus Ihrer Liste entfernt"),
(
"pim_room_delete_confirm",
"„{}“ löschen? Seine Buchungen gehen verloren.",
),
("pim_room_display", "Anzeigename"),
("pim_room_name", "Name (Teil der Adresse, nicht änderbar)"),
("pim_rooms", "Räume und Ressourcen"),
(
"pim_rooms_hint",
"Räume und Ressourcen können zu Terminen eingeladen werden. Sie nehmen freie Zeiten an und lehnen belegte selbst ab.",
),
("pim_save_err", "Speichern fehlgeschlagen: {}"),
("pim_save_here", "Hier speichern"),
("pim_save_to_folder", "In Ordner speichern…"),
("pim_server_address", "Serveradresse"),
(
"pim_server_address_hint",
"Wenn eine App den Server nicht findet, nutzen Sie diese Adresse:",
),
("pim_shared_by", "Geteilt von {}"),
("pim_shared_ok", "Geteilt mit {}"),
("pim_shared_with_me", "Mit mir geteilt"),
("pim_sharing", "Teilen"),
(
"pim_sharing_hint",
"Mit einem anderen Konto auf diesem Server teilen.",
),
("pim_show", "Einblenden"),
("pim_subscribe", "Abonnieren"),
(
"pim_transparent",
"Als frei anzeigen: Seine Termine blockieren bei Einladungen keine Zeit",
),
("pim_unshare", "Nicht mehr teilen"),
("pim_unshared", "Nicht mehr geteilt"),
("pim_user", "Benutzername"),
("pim_user_name", "Benutzername"),
("profile", "Profil"),
(
"profile_save_err",
@@ -1416,6 +1680,10 @@ const FR: &[(&str, &str)] = &[
k::ERR_CHALLENGE_EXPIRED,
"Cela a pris trop de temps. Veuillez réessayer.",
),
(
k::ERR_DEFAULT_CALENDAR,
"Le calendrier qui reçoit les invitations ne peut pas être supprimé.",
),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(k::ERR_FILE_NAME_REQUIRED, "un nom de fichier est requis"),
(k::ERR_FILES_EXIST, "certains fichiers existent déjà"),
@@ -1717,6 +1985,179 @@ const FR: &[(&str, &str)] = &[
"password_resets_signin",
"Définir un mot de passe supprime aussi les clés d'accès de ce compte et lève toute exigence à deux facteurs.",
),
("pim_access", "Accès"),
("pim_add", "Ajouter"),
("pim_address", "Adresse pour les applications"),
("pim_address_books", "Carnets d'adresses"),
("pim_app_passwords", "Créer un mot de passe d'application"),
("pim_auto", "automatique"),
("pim_birthdays", "Anniversaires"),
(
"pim_cal_hint",
"Choisissez un calendrier à gauche pour le gérer, ou connectez une application de calendrier.",
),
("pim_calendar", "Calendrier"),
("pim_calendars", "Calendriers"),
("pim_clients", "Applications"),
(
"pim_collection_urls",
"Adresses des calendriers et carnets d'adresses",
),
("pim_color", "Couleur"),
("pim_connect", "Connecter une application"),
(
"pim_connect_title",
"Connecter une application de calendrier ou de contacts",
),
("pim_contacts", "Contacts"),
(
"pim_contacts_hint",
"Choisissez un carnet d'adresses à gauche pour le gérer, ou connectez une application de contacts.",
),
("pim_created", "Créé"),
("pim_custom_color", "Couleur personnalisée"),
(
"pim_default_hint",
"Ce calendrier reçoit les invitations. Il ne peut pas être supprimé.",
),
(
"pim_delete_msg",
"Tout son contenu est supprimé. Les participants à vos réunions reçoivent une annulation.",
),
("pim_delete_title", "Supprimer « {} » ?"),
("pim_description", "Description"),
("pim_detail", "Détails"),
("pim_detail_busy", "Heures occupées seulement"),
("pim_detail_full", "Tous les détails"),
("pim_directory", "Annuaire"),
("pim_export", "Exporter"),
("pim_exported", "Enregistré sous {}"),
("pim_feeds", "Flux de calendriers et de contacts"),
(
"pim_feeds_hint",
"Chaque lien public de calendrier et de carnet d'adresses sur ce serveur.",
),
("pim_file_contacts", "{} contacts"),
("pim_file_events", "{} entrées"),
("pim_file_invalid", "{} non valides"),
("pim_general", "Général"),
(
"pim_generated_hint",
"Le serveur le remplit. Il est en lecture seule.",
),
("pim_hide", "Masquer"),
(
"pim_hint_apple",
"Apple : ajoutez un compte CalDAV et un compte CardDAV avec l'adresse du serveur. Apple exige HTTPS.",
),
(
"pim_hint_davx5",
"Android : dans DAVx⁵, connectez-vous avec URL et nom d'utilisateur, et utilisez l'adresse du serveur.",
),
(
"pim_hint_gnome",
"GNOME et Evolution : ajoutez un compte CalDAV ou WebDAV avec l'adresse du serveur.",
),
(
"pim_hint_thunderbird",
"Thunderbird : ajoutez un calendrier réseau avec l'adresse du serveur. Réglez son identité e-mail sur « Aucune », sinon il n'envoie pas d'invitations.",
),
("pim_import_btn", "Importer"),
("pim_import_done", "{} nouveaux, {} mis à jour."),
("pim_import_into", "Importer dans"),
(
"pim_import_menu",
"Importer dans le calendrier ou les contacts…",
),
("pim_import_skipped", "{} ignorés :"),
("pim_import_title", "Importer « {} »"),
("pim_importing", "Importation…"),
("pim_kind_resource", "Ressource"),
("pim_kind_room", "Salle"),
("pim_link_busy", "heures occupées seulement"),
("pim_link_full", "tous les détails"),
("pim_link_revoked", "Lien révoqué"),
("pim_links", "Liens publics"),
(
"pim_links_hint",
"Toute personne ayant le lien peut s'abonner. Google Agenda et Outlook.com exigent un lien sans mot de passe.",
),
(
"pim_links_hint_cards",
"Toute personne ayant le lien peut télécharger les contacts une fois. Les applications ne suivent pas les modifications.",
),
("pim_mode_ro", "Lecture seule"),
("pim_mode_rw", "Lecture et écriture"),
(
"pim_mode_schedule",
"Lecture, écriture et envoi d'invitations",
),
("pim_nav", "Calendrier et contacts"),
("pim_new_address_book", "Nouveau carnet d'adresses"),
("pim_new_calendar", "Nouveau calendrier"),
("pim_new_room", "Nouvelle salle"),
("pim_no_feeds", "Aucun flux public."),
("pim_no_links", "Aucun lien public."),
("pim_no_rooms", "Aucune salle ni ressource."),
("pim_no_shares", "Partagé avec personne."),
(
"pim_no_target",
"Vous n'avez aucun calendrier ni carnet d'adresses modifiable pour ce fichier.",
),
(
"pim_no_writable_folder",
"Vous n'avez aucun dossier modifiable.",
),
(
"pim_password_hint",
"Utilisez un mot de passe d'application plutôt que celui de votre compte.",
),
("pim_remove_from_list", "Retirer de ma liste"),
(
"pim_remove_msg",
"Vous ne le voyez plus. Le propriétaire peut le partager à nouveau.",
),
("pim_removed", "Retiré de votre liste"),
(
"pim_room_delete_confirm",
"Supprimer « {} » ? Ses réservations sont perdues.",
),
("pim_room_display", "Nom affiché"),
(
"pim_room_name",
"Nom (fait partie de l'adresse, non modifiable)",
),
("pim_rooms", "Salles et ressources"),
(
"pim_rooms_hint",
"Les salles et ressources peuvent être invitées aux réunions. Elles acceptent les créneaux libres et refusent les occupés d'elles-mêmes.",
),
("pim_save_err", "Impossible d'enregistrer : {}"),
("pim_save_here", "Enregistrer ici"),
("pim_save_to_folder", "Enregistrer dans un dossier…"),
("pim_server_address", "Adresse du serveur"),
(
"pim_server_address_hint",
"Si une application ne trouve pas le serveur, utilisez cette adresse :",
),
("pim_shared_by", "Partagé par {}"),
("pim_shared_ok", "Partagé avec {}"),
("pim_shared_with_me", "Partagés avec moi"),
("pim_sharing", "Partage"),
(
"pim_sharing_hint",
"Partager avec un autre compte de ce serveur.",
),
("pim_show", "Afficher"),
("pim_subscribe", "S'abonner"),
(
"pim_transparent",
"Afficher comme libre : ses événements ne bloquent pas de temps dans les invitations",
),
("pim_unshare", "Ne plus partager"),
("pim_unshared", "N'est plus partagé"),
("pim_user", "Nom d'utilisateur"),
("pim_user_name", "Nom d'utilisateur"),
("profile", "Profil"),
(
"profile_save_err",
▾Mweb/src/icons.rs
@@ -105,6 +105,14 @@ pub enum IconName {
ExpandMore,
/// material-symbols:expand-less
ExpandLess,
/// material-symbols:calendar-month
Calendar,
/// material-symbols:contacts
Contacts,
/// material-symbols:meeting-room
Room,
/// material-symbols:devices
Devices,
}
impl IconName {
@@ -245,6 +253,18 @@ impl IconName {
Self::SortDesc => {
r#"<path fill="currentColor" d="M11 4v12.175l-5.6-5.6L4 12l8 8l8-8l-1.4-1.425l-5.6 5.6V4z"/>"#
}
Self::Calendar => {
r#"<path fill="currentColor" d="M12 14q-.425 0-.712-.288T11 13t.288-.712T12 12t.713.288T13 13t-.288.713T12 14m-4.712-.288Q7 13.426 7 13t.288-.712T8 12t.713.288T9 13t-.288.713T8 14t-.712-.288M16 14q-.425 0-.712-.288T15 13t.288-.712T16 12t.713.288T17 13t-.288.713T16 14m-4 4q-.425 0-.712-.288T11 17t.288-.712T12 16t.713.288T13 17t-.288.713T12 18m-4.712-.288Q7 17.426 7 17t.288-.712T8 16t.713.288T9 17t-.288.713T8 18t-.712-.288M16 18q-.425 0-.712-.288T15 17t.288-.712T16 16t.713.288T17 17t-.288.713T16 18M5 22q-.825 0-1.412-.587T3 20V6q0-.825.588-1.412T5 4h1V2h2v2h8V2h2v2h1q.825 0 1.413.588T21 6v14q0 .825-.587 1.413T19 22zm0-2h14V10H5z"/>"#
}
Self::Contacts => {
r#"<path fill="currentColor" d="M4 23v-2h16v2zM4 3V1h16v2zm8 10q1.25 0 2.125-.875T15 10t-.875-2.125T12 7t-2.125.875T9 10t.875 2.125T12 13m-8 7q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm1.75-2h12.5q-1.125-1.4-2.725-2.2T12 15t-3.525.8T5.75 18"/>"#
}
Self::Room => {
r#"<path fill="currentColor" d="M3 21v-2h2V3h10v1h4v15h2v2h-4V6h-2v15zm8.713-8.287Q12 12.425 12 12t-.288-.712T11 11t-.712.288T10 12t.288.713T11 13t.713-.288"/>"#
}
Self::Devices => {
r#"<path fill="currentColor" d="M5 17q-.825 0-1.412-.587T3 15V6q0-.825.588-1.412T5 4h14q.825 0 1.413.588T21 6h-5.5q-1.45 0-2.475 1.025T12 9.5V17zm10.5 3q-.625 0-1.062-.437T14 18.5v-9q0-.625.438-1.062T15.5 8h5q.625 0 1.063.438T22 9.5v9q0 .625-.437 1.063T20.5 20zM2 20v-2h10v2zm16-7.5q.325 0 .538-.225t.212-.525q0-.325-.213-.537T18 11q-.3 0-.525.213t-.225.537q0 .3.225.525T18 12.5"/>"#
}
}
}
}
▾Mweb/src/router.rs
@@ -5,6 +5,7 @@
//! - `#/share/{token}/{segment/...}` — a public share (no login)
//! - `#/shares`, `#/users`, `#/settings/{tab}`, `#/uploads` — the signed-in
//! shell's manage views
//! - `#/calendar`, `#/contacts` — calendars and address books
//!
//! The URL hash is the single source of truth; the `Location` signal in the
//! app mirrors it. A folder listing carries `?page=&size=&sort=&desc=`, and
@@ -27,6 +28,8 @@ pub enum Section {
Settings,
/// This session's upload jobs (`#/uploads`).
Uploads,
Calendar,
Contacts,
}
impl Section {
@@ -40,6 +43,8 @@ impl Section {
Section::Users => Some("users"),
Section::Settings => Some("settings"),
Section::Uploads => Some("uploads"),
Section::Calendar => Some("calendar"),
Section::Contacts => Some("contacts"),
}
}
@@ -52,6 +57,8 @@ impl Section {
Section::Users,
Section::Settings,
Section::Uploads,
Section::Calendar,
Section::Contacts,
]
.into_iter()
.find(|s| s.segment() == Some(seg))
@@ -297,18 +304,22 @@ fn public_url() -> Option<String> {
PUBLIC_URL.with(|p| p.borrow().clone())
}
/// Build the WebDAV mount URL for a share token.
///
/// Origin-relative, not page-relative: the mount is a server route, so it does
/// not live under whatever path the app itself was loaded from. Only folder
/// shares have one; a file share has no collection to mount.
pub fn dav_share_url(token: &str) -> String {
/// The absolute URL of a server route: under the public URL when the
/// server has one, else the browser's origin. Origin-relative, not
/// page-relative: server routes do not live under the app's own path.
pub fn server_url(path: &str) -> String {
let origin = public_url().unwrap_or_else(|| {
web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default()
});
format!("{origin}{}/{token}", api_types::DAV_SHARE)
format!("{}{path}", origin.trim_end_matches('/'))
}
/// Build the WebDAV mount URL for a share token. Only folder shares have
/// one; a file share has no collection to mount.
pub fn dav_share_url(token: &str) -> String {
server_url(&format!("{}/{token}", api_types::DAV_SHARE))
}
/// Build the share link for a token: under the public URL when the server
▾Mweb/src/views/admin.rs
@@ -5,7 +5,7 @@ use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
use crate::api::{self, AdminUser, Me, Mode, RoomInfo, RoomKind};
use crate::components::icon::Icon;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
@@ -874,3 +874,221 @@ fn UserForm(
</Modal>
}
}
// ---------------------------------------------------------------------------
// Rooms and resources
// ---------------------------------------------------------------------------
/// Rooms and resources: principals without a sign-in that can be invited to
/// meetings and answer on their own.
#[component]
pub fn RoomsPanel() -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (rooms, set_rooms) = signal(Option::<Vec<RoomInfo>>::None);
// `Some(None)` creates a room, `Some(Some(room))` renames one.
let (editing, set_editing) = signal(Option::<Option<RoomInfo>>::None);
let (deleting, set_deleting) = signal(Option::<RoomInfo>::None);
let reload = move || {
spawn_local(async move {
match api::list_rooms().await {
Ok(r) => set_rooms.set(Some(r)),
Err(e) => show_error(toast, e.to_string()),
}
});
};
reload();
let delete = Callback::new(move |_| {
let Some(room) = deleting.get() else { return };
set_deleting.set(None);
spawn_local(async move {
match api::delete_room(room.id).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::DELETED));
reload();
}
Err(e) => show_error(toast, e.to_string()),
}
});
});
view! {
<div class="users-tab">
<div class="users-header">
<h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_ROOMS)}</h3>
<button class="btn btn-primary" on:click=move |_| set_editing.set(Some(None))>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{i18n::tr(i18n::k::PIM_NEW_ROOM)}
</button>
</div>
<p class="muted view-hint rooms-hint">{i18n::tr(i18n::k::PIM_ROOMS_HINT)}</p>
{move || match rooms.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::PIM_NO_ROOMS)}</p> }.into_any()
}
Some(list) => view! {
<div class="user-list">
{list
.into_iter()
.map(|r| {
let (edit, del) = (r.clone(), r.clone());
let kind = match r.kind {
RoomKind::Room => i18n::t(i18n::k::PIM_KIND_ROOM),
RoomKind::Resource => i18n::t(i18n::k::PIM_KIND_RESOURCE),
};
view! {
<div class="user-row">
<div class="user-row-main">
<div class="user-row-name">
<span class="ic-admin"><Icon name=IconName::Room/></span>
{r.display_name.clone()}
<span class="badge">{kind}</span>
</div>
<div class="muted room-address">{r.name.clone()}</div>
</div>
<div class="user-row-actions">
<button
class="btn btn-sm"
on:click=move |_| set_editing.set(Some(Some(edit.clone())))
>
{i18n::tr(i18n::k::RENAME)}
</button>
<button
class="btn btn-sm btn-danger"
on:click=move |_| set_deleting.set(Some(del.clone()))
>
{i18n::tr(i18n::k::DELETE)}
</button>
</div>
</div>
}
})
.collect::<Vec<_>>()}
</div>
}
.into_any(),
}}
{move || editing.get().map(|existing| view! {
<RoomForm
existing=existing
close=Callback::new(move |_| set_editing.set(None))
on_saved=Callback::new(move |_| reload())
/>
})}
{move || deleting.get().map(|r| view! {
<crate::views::dialogs::ConfirmDialog
title=i18n::t(i18n::k::DELETE).to_string()
message=i18n::t_fmt(i18n::k::PIM_ROOM_DELETE_CONFIRM, &r.display_name)
submit=i18n::t(i18n::k::DELETE).to_string()
danger=true
on_submit=delete
on_close=Callback::new(move |_| set_deleting.set(None))
/>
})}
</div>
}
}
#[component]
fn RoomForm(
existing: Option<RoomInfo>,
close: Callback<()>,
on_saved: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_new = existing.is_none();
let id = existing.as_ref().map(|r| r.id);
let (name, set_name) = signal(
existing
.as_ref()
.map(|r| r.name.clone())
.unwrap_or_default(),
);
let (display, set_display) = signal(
existing
.as_ref()
.map(|r| r.display_name.clone())
.unwrap_or_default(),
);
let (kind, set_kind) = signal(existing.as_ref().map_or(RoomKind::Room, |r| r.kind));
let (busy, set_busy) = signal(false);
let save = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
if busy.get() {
return;
}
set_busy.set(true);
let (n, d, k) = (
name.get().trim().to_string(),
display.get().trim().to_string(),
kind.get(),
);
spawn_local(async move {
let result = match id {
None => api::create_room(&n, &d, k).await,
Some(id) => api::update_room(id, &d).await,
};
match result {
Ok(_) => {
show(toast, i18n::t(i18n::k::SAVED));
on_saved.run(());
close.run(());
}
Err(e) => {
show_error(toast, e.to_string());
set_busy.set(false);
}
}
});
};
view! {
<Modal class="user-form" on_close=close>
<form on:submit=save>
<h2 class="modal-title">
{if is_new { i18n::t(i18n::k::PIM_NEW_ROOM) } else { i18n::t(i18n::k::RENAME) }}
</h2>
<label class="field">
<span class="field-label">{i18n::tr(i18n::k::PIM_ROOM_NAME)}</span>
<input
class="field-input"
required=true
disabled=!is_new
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
<label class="field">
<span class="field-label">{i18n::tr(i18n::k::PIM_ROOM_DISPLAY)}</span>
<input
class="field-input"
required=!is_new
prop:value=move || display.get()
on:input=move |ev| set_display.set(event_target_value(&ev))
/>
</label>
{is_new.then(|| view! {
<label class="field">
<span class="field-label">{i18n::tr(i18n::k::TYPE)}</span>
<select
class="field-input"
on:change=move |ev| {
set_kind.set(match event_target_value(&ev).as_str() {
"resource" => RoomKind::Resource,
_ => RoomKind::Room,
})
}
>
<option value="room">{i18n::t(i18n::k::PIM_KIND_ROOM)}</option>
<option value="resource">{i18n::t(i18n::k::PIM_KIND_RESOURCE)}</option>
</select>
</label>
})}
<div class="modal-actions">
<button type="button" class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
<button type="submit" class="btn btn-primary" disabled=move || busy.get()>
{move || if busy.get() { i18n::t(i18n::k::SAVING) } else { i18n::t(i18n::k::SAVE) }}
</button>
</div>
</form>
</Modal>
}
}
▾Mweb/src/views/browser.rs
@@ -1692,6 +1692,29 @@ fn menu_items(
),
));
}
if let (Some(kind), Some(rid), None) = (
crate::views::pim::import_kind(&e.name).filter(|_| !e.is_dir),
root_id,
&loc_now.share_token,
) {
let (name, path) = (e.name.clone(), join_path(&loc_now.path, &e.name));
v.push(MenuItem::new(
match kind {
api::PimCollectionKind::Calendar => IconName::Calendar,
api::PimCollectionKind::Addressbook => IconName::Contacts,
},
i18n::t(i18n::k::PIM_IMPORT_MENU),
owner.with(|| {
Callback::new(move |_| {
set_dialog.set(Some(Dialog::PimImport {
root_id: rid,
path: path.clone(),
name: name.clone(),
}))
})
}),
));
}
v.push(MenuItem::new(
IconName::Info,
i18n::t(i18n::k::INFO),
▾Mweb/src/views/dialogs.rs
@@ -109,6 +109,12 @@ pub enum Dialog {
root_name: String,
rel_path: String,
},
/// Import an `.ics` or `.vcf` file into a calendar or address book.
PimImport {
root_id: i64,
path: String,
name: String,
},
/// Create a share link for an item.
Share {
name: String,
@@ -247,6 +253,19 @@ pub fn DialogView(
<InfoDialog entry=entry root_name=root_name rel_path=rel_path on_close=close/>
}
.into_any(),
Dialog::PimImport {
root_id,
path,
name,
} => view! {
<crate::views::pim::PimImportDialog
root_id=root_id
path=path
name=name
on_close=close
/>
}
.into_any(),
Dialog::Share {
name,
root_id,
▾Mweb/src/views/mod.rs
@@ -3,6 +3,7 @@ pub mod browser;
pub mod dialogs;
pub mod file_view;
pub mod login;
pub mod pim;
pub mod search;
pub mod security;
pub mod setup;
▾Aweb/src/views/pim.rs
@@ -0,0 +1,1297 @@
//! Calendars and address books: the collection list beside each view, the
//! dialog that manages one collection, the client setup help and the import
//! of an `.ics` or `.vcf` file.
use std::collections::HashSet;
use api_types::{CreatePimCollection, CreatePimLink, UpdatePimCollection};
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{
self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview,
PimShareInfo, PimShareMode,
};
use crate::components::icon::{Icon, icon_svg};
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n::{self, k};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate, server_url};
use crate::util::{copy_to_clipboard, format_date, storage_get, storage_set};
use crate::views::dialogs::{ConfirmDialog, Dialog};
use crate::views::shares::expiry_to_rfc3339;
/// Colors offered for a collection; a custom one comes from the color input.
const PALETTE: [&str; 8] = [
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b",
];
/// The collections the lists do not show, by id.
const HIDDEN_KEY: &str = "fbng.pim-hidden";
/// Collection ids hidden in the calendar and contact views.
pub fn load_hidden() -> HashSet<i64> {
storage_get(HIDDEN_KEY)
.unwrap_or_default()
.split(',')
.filter_map(|s| s.parse().ok())
.collect()
}
fn save_hidden(ids: &HashSet<i64>) {
let list: Vec<String> = ids.iter().map(i64::to_string).collect();
storage_set(HIDDEN_KEY, &list.join(","));
}
/// A collection's color for a style attribute. Colors of lent collections
/// come from other clients, so anything but a hex color falls back.
pub fn safe_color(c: Option<&str>) -> String {
match c.and_then(|c| c.strip_prefix('#')) {
Some(h) if [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()) => {
format!("#{h}")
}
_ => "var(--accent)".to_string(),
}
}
/// The name to show: generated collections have translated names.
pub fn display_name(c: &PimCollectionInfo) -> String {
match (c.generated, c.kind) {
(true, PimCollectionKind::Calendar) => i18n::t(k::PIM_BIRTHDAYS).to_string(),
(true, PimCollectionKind::Addressbook) => i18n::t(k::PIM_DIRECTORY).to_string(),
_ => c.name.clone(),
}
}
/// Whether the signed-in user may add objects to a collection.
pub fn writable(c: &PimCollectionInfo) -> bool {
!c.generated && c.mode != Some(PimShareMode::Ro)
}
fn mode_label(m: PimShareMode) -> &'static str {
match m {
PimShareMode::Ro => i18n::t(k::PIM_MODE_RO),
PimShareMode::Rw => i18n::t(k::PIM_MODE_RW),
PimShareMode::RwSchedule => i18n::t(k::PIM_MODE_SCHEDULE),
}
}
/// The browser's IANA time zone, for dates the server resolves.
pub fn browser_tz() -> String {
let opts = js_sys::Intl::DateTimeFormat::new(&js_sys::Array::new(), &js_sys::Object::new())
.resolved_options();
js_sys::Reflect::get(&opts, &"timeZone".into())
.ok()
.and_then(|v| v.as_string())
.unwrap_or_else(|| "UTC".to_string())
}
/// The calendar or contacts section: the collection list, and the view of
/// what the visible collections hold.
#[component]
pub fn PimView(
kind: PimCollectionKind,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let (collections, set_collections) = signal(Option::<Vec<PimCollectionInfo>>::None);
let (load_err, set_load_err) = signal(Option::<String>::None);
let hidden = RwSignal::new(load_hidden());
let reload = Callback::new(move |_| {
set_load_err.set(None);
spawn_local(async move {
match api::pim_collections().await {
Ok(v) => set_collections.set(Some(v)),
Err(e) => set_load_err.set(Some(e.to_string())),
}
});
});
reload.run(());
let (glyph, title, hint) = match kind {
PimCollectionKind::Calendar => (IconName::Calendar, k::PIM_CALENDAR, k::PIM_CAL_HINT),
PimCollectionKind::Addressbook => {
(IconName::Contacts, k::PIM_CONTACTS, k::PIM_CONTACTS_HINT)
}
};
view! {
<div class="pim-view">
<CollectionList
kind=kind
collections=collections
load_err=load_err
hidden=hidden
reload=reload
me=me
set_dialog=set_dialog
/>
<section class="pim-main">
<div class="empty-state">
{icon_svg(glyph, "empty-glyph")}
<h3>{i18n::tr(title)}</h3>
<p class="muted">{i18n::tr(hint)}</p>
</div>
</section>
</div>
}
}
#[component]
fn CollectionList(
kind: PimCollectionKind,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
load_err: ReadSignal<Option<String>>,
hidden: RwSignal<HashSet<i64>>,
reload: Callback<()>,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let (managing, set_managing) = signal(Option::<PimCollectionInfo>::None);
let (creating, set_creating) = signal(false);
let (connecting, set_connecting) = signal(false);
let calendars = kind == PimCollectionKind::Calendar;
let (title, new_label) = match calendars {
true => (k::PIM_CALENDARS, k::PIM_NEW_CALENDAR),
false => (k::PIM_ADDRESS_BOOKS, k::PIM_NEW_ADDRESS_BOOK),
};
let row = move |c: PimCollectionInfo| {
let id = c.id;
let is_hidden = move || hidden.with(|h| h.contains(&id));
let toggle = move |_| {
hidden.update(|h| {
if !h.remove(&id) {
h.insert(id);
}
save_hidden(h);
});
};
let note = match (c.generated, c.mode) {
(true, _) => Some(i18n::t(k::PIM_AUTO).to_string()),
(false, Some(_)) => Some(c.owner.clone()),
_ => None,
};
let name = display_name(&c);
let color = safe_color(c.color.as_deref());
let info = c.clone();
view! {
<div class="pim-row" class:pim-row-hidden=is_hidden>
<button
class="pim-toggle"
title=move || if is_hidden() { i18n::t(k::PIM_SHOW) } else { i18n::t(k::PIM_HIDE) }
aria-pressed=move || (!is_hidden()).to_string()
on:click=toggle
>
<span class="pim-swatch" style=format!("--swatch: {color}")></span>
</button>
<button
class="pim-row-main"
title=name.clone()
on:click=move |_| set_managing.set(Some(info.clone()))
>
<span class="pim-row-name">{name.clone()}</span>
{note.map(|n| view! { <span class="pim-row-note">{n}</span> })}
</button>
</div>
}
};
view! {
<aside class="pim-side">
<div class="pim-side-head">
<h3 class="admin-section-title">{i18n::tr(title)}</h3>
<button
class="icon-btn icon-btn-sm"
title=i18n::tr(k::PIM_CONNECT)
aria-label=i18n::tr(k::PIM_CONNECT)
on:click=move |_| set_connecting.set(true)
>
<Icon name=IconName::Devices class="ic-btn".to_string()/>
</button>
<button
class="icon-btn icon-btn-sm"
title=i18n::tr(new_label)
aria-label=i18n::tr(new_label)
on:click=move |_| set_creating.set(true)
>
<Icon name=IconName::Add class="ic-btn".to_string()/>
</button>
</div>
{move || match collections.get() {
None => match load_err.get() {
Some(msg) => view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| reload.run(())>
{i18n::tr(k::RETRY)}
</button>
</div>
}
.into_any(),
None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
},
Some(list) => {
let (mine, lent): (Vec<_>, Vec<_>) = list
.into_iter()
.filter(|c| c.kind == kind)
.partition(|c| c.mode.is_none());
let lent_block = (!lent.is_empty()).then(|| view! {
<div class="pim-group-label">{i18n::tr(k::PIM_SHARED_WITH_ME)}</div>
{lent.into_iter().map(row).collect::<Vec<_>>()}
});
view! {
<div class="pim-list">
{mine.into_iter().map(row).collect::<Vec<_>>()}
{lent_block}
</div>
}
.into_any()
}
}}
</aside>
{move || managing.get().map(|info| view! {
<CollectionDialog
info=info
me=me
set_dialog=set_dialog
on_close=Callback::new(move |_| set_managing.set(None))
on_changed=reload
/>
})}
<Show when=move || creating.get()>
<NewCollectionDialog
kind=kind
on_close=Callback::new(move |_| set_creating.set(false))
on_created=reload
/>
</Show>
<Show when=move || connecting.get()>
<ConnectDialog
me=me
collections=collections
on_close=Callback::new(move |_| set_connecting.set(false))
/>
</Show>
}
}
/// The palette plus a custom color. `None` clears the color.
#[component]
fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
view! {
<div class="pim-colors" role="radiogroup" aria-label=i18n::tr(k::PIM_COLOR)>
{PALETTE
.iter()
.map(|&c| {
view! {
<button
type="button"
class="pim-color"
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()
title=c
style=format!("--swatch: {c}")
on:click=move |_| color.set(Some(c.to_string()))
></button>
}
})
.collect::<Vec<_>>()}
<input
type="color"
class="pim-color-input"
title=i18n::tr(k::PIM_CUSTOM_COLOR)
aria-label=i18n::tr(k::PIM_CUSTOM_COLOR)
prop:value=move || {
color.with(|v| {
v.as_deref()
.filter(|c| c.len() >= 7)
.map(|c| c[..7].to_string())
.unwrap_or_else(|| "#3b82f6".to_string())
})
}
on:input=move |ev| color.set(Some(event_target_value(&ev)))
/>
</div>
}
}
#[component]
fn NewCollectionDialog(
kind: PimCollectionKind,
on_close: Callback<()>,
on_created: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (name, set_name) = signal(String::new());
let color = RwSignal::new(Some(PALETTE[0].to_string()));
let (description, set_description) = signal(String::new());
let (busy, set_busy) = signal(false);
let title = match kind {
PimCollectionKind::Calendar => k::PIM_NEW_CALENDAR,
PimCollectionKind::Addressbook => k::PIM_NEW_ADDRESS_BOOK,
};
let submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let n = name.get().trim().to_string();
if n.is_empty() || busy.get() {
return;
}
set_busy.set(true);
let body = CreatePimCollection {
kind,
name: n,
color: color.get(),
description: Some(description.get()).filter(|d| !d.trim().is_empty()),
components: Vec::new(),
};
spawn_local(async move {
match api::pim_create_collection(body).await {
Ok(_) => {
show(toast, i18n::t(k::PIM_CREATED));
on_created.run(());
on_close.run(());
}
Err(e) => {
show_error(toast, e.to_string());
set_busy.set(false);
}
}
});
};
view! {
<Modal class="pim-modal" on_close=on_close>
<form on:submit=submit>
<h2 class="modal-title">{i18n::tr(title)}</h2>
<label class="field">
<span class="field-label">{i18n::tr(k::NAME)}</span>
<input
class="field-input"
required=true
maxlength="200"
autofocus=true
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
<div class="field">
<span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
<ColorPicker color=color/>
</div>
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
<textarea
class="field-input"
rows="2"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</label>
<div class="modal-actions">
<button type="button" class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(k::CANCEL)}
</button>
<button type="submit" class="btn btn-primary" disabled=move || busy.get()>
{i18n::tr(k::CREATE)}
</button>
</div>
</form>
</Modal>
}
}
/// A read-only URL with a copy button.
fn url_row(url: String, toast: ToastMsg) -> impl IntoView {
let copy = url.clone();
view! {
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
value=url
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
<button
type="button"
class="btn"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(k::COPY)}
</button>
</div>
}
}
/// Everything about one collection: its settings, who it is lent to, its
/// public links, export and deletion.
#[component]
fn CollectionDialog(
info: PimCollectionInfo,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
on_close: Callback<()>,
on_changed: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let own = info.mode.is_none() && !info.generated;
let url = server_url(&info.url);
let lent_note = info.mode.map(|m| {
format!(
"{} · {}",
i18n::t_fmt(k::PIM_SHARED_BY, &info.owner),
mode_label(m)
)
});
let generated_note = info.generated.then(|| i18n::t(k::PIM_GENERATED_HINT));
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>
</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/> })}
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_ADDRESS)}</h3>
{url_row(url, toast)}
</section>
{own.then(|| view! { <SharingSection info=info.clone()/> })}
{own.then(|| view! { <LinksSection info=info.clone()/> })}
<ExportSection info=info.clone() me=me set_dialog=set_dialog/>
{(!info.generated).then(|| view! {
<DeleteSection info=info.clone() on_close=on_close on_changed=on_changed/>
})}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CLOSE)}</button>
</div>
</Modal>
}
}
#[component]
fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let calendar = info.kind == PimCollectionKind::Calendar;
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());
let (transparent, set_transparent) = signal(info.transparent);
let (busy, set_busy) = signal(false);
let save = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
if busy.get() || name.get().trim().is_empty() {
return;
}
set_busy.set(true);
let body = UpdatePimCollection {
name: Some(name.get()),
color: Some(color.get().unwrap_or_default()),
description: Some(description.get()),
transparent: calendar.then(|| transparent.get()),
};
spawn_local(async move {
match api::pim_update_collection(id, body).await {
Ok(_) => {
show(toast, i18n::t(k::SAVED));
on_changed.run(());
}
Err(e) => show_error(toast, i18n::t_fmt(k::PIM_SAVE_ERR, &e.to_string())),
}
set_busy.set(false);
});
};
view! {
<form class="pim-section" on:submit=save>
<h3 class="pim-section-title">{i18n::tr(k::PIM_GENERAL)}</h3>
<label class="field">
<span class="field-label">{i18n::tr(k::NAME)}</span>
<input
class="field-input"
required=true
maxlength="200"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
<div class="field">
<span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
<ColorPicker color=color/>
</div>
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
<textarea
class="field-input"
rows="2"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</label>
{calendar.then(|| view! {
<label class="check-row">
<input
type="checkbox"
prop:checked=move || transparent.get()
on:change=move |ev| set_transparent.set(event_target_checked(&ev))
/>
{i18n::tr(k::PIM_TRANSPARENT)}
</label>
})}
<div class="pim-section-actions">
<button type="submit" class="btn btn-primary btn-sm" disabled=move || busy.get()>
{move || if busy.get() { i18n::t(k::SAVING) } else { i18n::t(k::SAVE) }}
</button>
</div>
</form>
}
}
#[component]
fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let calendar = info.kind == PimCollectionKind::Calendar;
let modes: Vec<PimShareMode> = match calendar {
true => vec![PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule],
false => vec![PimShareMode::Ro, PimShareMode::Rw],
};
let (shares, set_shares) = signal(Option::<Vec<PimShareInfo>>::None);
let (user, set_user) = signal(String::new());
let (mode, set_mode) = signal(PimShareMode::Ro);
let load = move || {
spawn_local(async move {
match api::pim_shares(id).await {
Ok(v) => set_shares.set(Some(v)),
Err(e) => show_error(toast, e.to_string()),
}
});
};
load();
let set = move |who: String, m: PimShareMode| {
spawn_local(async move {
match api::pim_share(id, &who, m).await {
Ok(s) => {
show(toast, i18n::t_fmt(k::PIM_SHARED_OK, &s.user_name));
set_user.set(String::new());
load();
}
Err(e) => show_error(toast, e.to_string()),
}
});
};
let add = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let who = user.get().trim().to_string();
if !who.is_empty() {
set(who, mode.get());
}
};
let mode_options = move |current: PimShareMode| {
modes
.iter()
.map(|&m| {
view! {
<option value=m.as_str() selected=m == current>{mode_label(m)}</option>
}
})
.collect::<Vec<_>>()
};
let mode_options_add = mode_options.clone();
view! {
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_SHARING)}</h3>
<p class="muted pim-hint">{i18n::tr(k::PIM_SHARING_HINT)}</p>
{move || match shares.get() {
None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted pim-hint">{i18n::tr(k::PIM_NO_SHARES)}</p> }.into_any()
}
Some(list) => {
let options = mode_options.clone();
view! {
<div class="pim-items">
{list
.into_iter()
.map(|s| {
let (uid, who) = (s.user_id, s.user_name.clone());
view! {
<div class="pim-item">
<span class="pim-item-name">{s.user_name.clone()}</span>
<select
class="pim-mode"
aria-label=i18n::tr(k::PIM_ACCESS)
on:change=move |ev| {
if let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) {
set(who.clone(), m);
}
}
>
{options(s.mode)}
</select>
<button
type="button"
class="icon-btn icon-btn-sm"
title=i18n::tr(k::PIM_UNSHARE)
aria-label=i18n::tr(k::PIM_UNSHARE)
on:click=move |_| {
spawn_local(async move {
match api::pim_unshare(id, uid).await {
Ok(_) => {
show(toast, i18n::t(k::PIM_UNSHARED));
load();
}
Err(e) => show_error(toast, e.to_string()),
}
});
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
})
.collect::<Vec<_>>()}
</div>
}
.into_any()
}
}}
<form class="pim-add" on:submit=add>
<input
class="field-input"
required=true
placeholder=i18n::tr(k::PIM_USER_NAME)
aria-label=i18n::tr(k::PIM_USER_NAME)
autocomplete="off"
prop:value=move || user.get()
on:input=move |ev| set_user.set(event_target_value(&ev))
/>
<select
class="pim-mode"
aria-label=i18n::tr(k::PIM_ACCESS)
on:change=move |ev| {
if let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) {
set_mode.set(m);
}
}
>
{mode_options_add(PimShareMode::Ro)}
</select>
<button type="submit" class="btn btn-sm">{i18n::tr(k::PIM_ADD)}</button>
</form>
</section>
}
}
#[component]
fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let calendar = info.kind == PimCollectionKind::Calendar;
let (links, set_links) = signal(Option::<Vec<PimLinkInfo>>::None);
let (busy_only, set_busy_only) = signal(false);
let (choice, set_choice) = signal("never".to_string());
let (custom, set_custom) = signal(String::new());
let (password, set_password) = signal(String::new());
let (busy, set_busy) = signal(false);
let load = move || {
spawn_local(async move {
match api::pim_links(id).await {
Ok(v) => set_links.set(Some(v)),
Err(e) => show_error(toast, e.to_string()),
}
});
};
load();
let create = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
if busy.get() {
return;
}
set_busy.set(true);
let pw = password.get().trim().to_string();
let body = CreatePimLink {
busy_only: busy_only.get(),
expires_at: expiry_to_rfc3339(&choice.get(), &custom.get()),
password: (!pw.is_empty()).then_some(pw),
};
spawn_local(async move {
match api::pim_create_link(id, body).await {
Ok(_) => {
set_password.set(String::new());
load();
}
Err(e) => show_error(toast, e.to_string()),
}
set_busy.set(false);
});
};
let link_row = move |l: PimLinkInfo| {
let full = server_url(&l.path);
// `webcal://` opens the calendar app; the transport stays HTTP(S).
let webcal = full
.strip_prefix("https://")
.or_else(|| full.strip_prefix("http://"))
.map(|rest| format!("webcal://{rest}"));
let mut meta = vec![if l.busy_only {
i18n::t(k::PIM_LINK_BUSY).to_string()
} else {
i18n::t(k::PIM_LINK_FULL).to_string()
}];
if let Some(e) = &l.expires_at {
meta.push(i18n::t_fmt(k::META_EXPIRES, &format_date(e)));
}
if l.has_password {
meta.push(i18n::t(k::SHARE_PROTECTED).to_string());
}
let link_id = l.id;
let copy = full.clone();
view! {
<div class="pim-link">
<div class="pim-link-main">
<span class="pim-link-url" title=full.clone()>{full.clone()}</span>
<span class="muted pim-link-meta">{meta.join(" · ")}</span>
</div>
<div class="pim-link-actions">
<button
type="button"
class="btn btn-sm"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(k::COPY)}
</button>
{calendar.then(|| webcal.map(|w| view! {
<a class="btn btn-sm" href=w>{i18n::t(k::PIM_SUBSCRIBE)}</a>
}))}
<button
type="button"
class="btn btn-sm btn-danger"
on:click=move |_| {
spawn_local(async move {
match api::pim_delete_link(id, link_id).await {
Ok(_) => {
show(toast, i18n::t(k::PIM_LINK_REVOKED));
load();
}
Err(e) => show_error(toast, e.to_string()),
}
});
}
>
{i18n::t(k::DELETE)}
</button>
</div>
</div>
}
};
view! {
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_LINKS)}</h3>
<p class="muted pim-hint">
{move || match calendar {
true => i18n::t(k::PIM_LINKS_HINT),
false => i18n::t(k::PIM_LINKS_HINT_CARDS),
}}
</p>
{move || match links.get() {
None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted pim-hint">{i18n::tr(k::PIM_NO_LINKS)}</p> }.into_any()
}
Some(list) => view! {
<div class="pim-items">{list.into_iter().map(link_row).collect::<Vec<_>>()}</div>
}
.into_any(),
}}
<form class="pim-link-form" on:submit=create>
{calendar.then(|| view! {
<label class="pim-inline">
<span>{i18n::tr(k::PIM_DETAIL)}</span>
<select on:change=move |ev| set_busy_only.set(event_target_value(&ev) == "busy")>
<option value="full">{i18n::t(k::PIM_DETAIL_FULL)}</option>
<option value="busy">{i18n::t(k::PIM_DETAIL_BUSY)}</option>
</select>
</label>
})}
<label class="pim-inline">
<span>{i18n::tr(k::EXPIRES)}</span>
<select on:change=move |ev| set_choice.set(event_target_value(&ev))>
<option value="never">{i18n::t(k::NEVER)}</option>
<option value="1d">{i18n::t(k::EXPIRY_1D)}</option>
<option value="1w">{i18n::t(k::EXPIRY_1W)}</option>
<option value="1mo">{i18n::t(k::EXPIRY_1MO)}</option>
<option value="custom">{i18n::t(k::CUSTOM)}</option>
</select>
</label>
<Show when=move || choice.get() == "custom">
<label class="pim-inline">
<span>{i18n::tr(k::UNTIL)}</span>
<input
type="datetime-local"
prop:value=move || custom.get()
on:change=move |ev| set_custom.set(event_target_value(&ev))
/>
</label>
</Show>
<label class="pim-inline">
<span>{i18n::tr(k::PASSWORD)}</span>
<input
type="password"
autocomplete="new-password"
prop:value=move || password.get()
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
</label>
<div class="pim-section-actions">
<button type="submit" class="btn btn-sm" disabled=move || busy.get()>
{i18n::tr(k::CREATE_LINK)}
</button>
</div>
</form>
</section>
}
}
/// A file name for an export: the collection name without path separators.
fn export_file(info: &PimCollectionInfo) -> String {
let ext = match info.kind {
PimCollectionKind::Calendar => "ics",
PimCollectionKind::Addressbook => "vcf",
};
let base: String = display_name(info)
.chars()
.map(|c| {
if matches!(c, '/' | '\\' | ':') {
'_'
} else {
c
}
})
.collect();
format!("{}.{ext}", base.trim())
}
#[component]
fn ExportSection(
info: PimCollectionInfo,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let file = export_file(&info);
let download = {
let file = file.clone();
move |_| api::trigger_download(&api::pim_export_url(id), &file)
};
let save = move |_| {
let roots: Vec<_> = me.with_untracked(|m| {
m.as_ref()
.map(|m| {
m.roots
.iter()
.filter(|r| r.mode.is_writable())
.cloned()
.collect()
})
.unwrap_or_default()
});
let Some(first) = roots.first().map(|r| r.id) else {
show_error(toast, i18n::t(k::PIM_NO_WRITABLE_FOLDER));
return;
};
let file = file.clone();
set_dialog.set(Some(Dialog::Picker {
title: i18n::t(k::PIM_SAVE_TO_FOLDER).to_string(),
confirm: i18n::t(k::PIM_SAVE_HERE).to_string(),
roots,
root: first,
dir: String::new(),
for_write: false,
on_pick: Callback::new(move |(root, dir): (i64, String)| {
let path = match dir.is_empty() {
true => file.clone(),
false => format!("{dir}/{file}"),
};
spawn_local(async move {
match api::pim_export_to_root(id, root, &path).await {
Ok(_) => show(toast, i18n::t_fmt(k::PIM_EXPORTED, &path)),
Err(e) => show_error(toast, e.to_string()),
}
});
}),
}));
};
view! {
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_EXPORT)}</h3>
<div class="pim-section-actions pim-left">
<button type="button" class="btn btn-sm" on:click=download>
{icon_svg(IconName::Download, "ic-btn")}
{i18n::t(k::DOWNLOAD)}
</button>
<button type="button" class="btn btn-sm" on:click=save>
{icon_svg(IconName::Folder, "ic-btn")}
{i18n::t(k::PIM_SAVE_TO_FOLDER)}
</button>
</div>
</section>
}
}
#[component]
fn DeleteSection(
info: PimCollectionInfo,
on_close: Callback<()>,
on_changed: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (confirm, set_confirm) = signal(false);
let id = info.id;
let lent = info.mode.is_some();
let name = display_name(&info);
let run = Callback::new(move |_| {
set_confirm.set(false);
spawn_local(async move {
match api::pim_delete_collection(id).await {
Ok(_) => {
show(
toast,
match lent {
true => i18n::t(k::PIM_REMOVED),
false => i18n::t(k::DELETED),
},
);
on_changed.run(());
on_close.run(());
}
Err(e) => show_error(toast, e.to_string()),
}
});
});
view! {
<section class="pim-section">
{match (lent, info.is_default) {
(_, true) => view! { <p class="muted pim-hint">{i18n::tr(k::PIM_DEFAULT_HINT)}</p> }.into_any(),
(true, _) => view! {
<button type="button" class="btn btn-sm" on:click=move |_| set_confirm.set(true)>
{i18n::tr(k::PIM_REMOVE_FROM_LIST)}
</button>
}
.into_any(),
(false, _) => view! {
<button type="button" class="btn btn-sm btn-danger" on:click=move |_| set_confirm.set(true)>
{icon_svg(IconName::Delete, "ic-btn")}
{i18n::t(k::DELETE)}
</button>
}
.into_any(),
}}
</section>
<Show when=move || confirm.get()>
<ConfirmDialog
title=i18n::t_fmt(k::PIM_DELETE_TITLE, &name)
message=match lent {
true => i18n::t(k::PIM_REMOVE_MSG).to_string(),
false => i18n::t(k::PIM_DELETE_MSG).to_string(),
}
submit=match lent {
true => i18n::t(k::PIM_REMOVE_FROM_LIST).to_string(),
false => i18n::t(k::DELETE).to_string(),
}
danger=!lent
on_submit=run
on_close=Callback::new(move |_| set_confirm.set(false))
/>
</Show>
}
}
/// How to set up a calendar or contacts app against this server.
#[component]
fn ConnectDialog(
me: ReadSignal<Option<Me>>,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_close: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let user = me.with_untracked(|m| {
m.as_ref()
.and_then(|m| m.user.as_ref())
.map(|u| u.name.clone())
.unwrap_or_default()
});
let app_passwords = move |_| {
on_close.run(());
navigate(&Location {
section: Section::Settings,
path: vec!["security".to_string()],
..Location::root()
});
};
view! {
<Modal class="pim-modal" on_close=on_close>
<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)}
<p class="muted pim-hint">{i18n::tr(k::PIM_SERVER_ADDRESS_HINT)}</p>
{url_row(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)}
<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>
{i18n::tr(k::PIM_APP_PASSWORDS)}
</button>
</div>
</section>
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_CLIENTS)}</h3>
<ul class="pim-hints">
<li>{i18n::tr(k::PIM_HINT_APPLE)}</li>
<li>{i18n::tr(k::PIM_HINT_DAVX5)}</li>
<li>{i18n::tr(k::PIM_HINT_THUNDERBIRD)}</li>
<li>{i18n::tr(k::PIM_HINT_GNOME)}</li>
</ul>
</section>
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_COLLECTION_URLS)}</h3>
<div class="pim-items">
{move || {
collections
.get()
.unwrap_or_default()
.into_iter()
.map(|c| {
let url = server_url(&c.url);
let copy = url.clone();
view! {
<div class="pim-item">
<span class="pim-swatch" style=format!("--swatch: {}", safe_color(c.color.as_deref()))></span>
<span class="pim-item-name" title=url.clone()>{display_name(&c)}</span>
<button
type="button"
class="btn btn-sm"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(k::COPY)}
</button>
</div>
}
})
.collect::<Vec<_>>()
}}
</div>
</section>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CLOSE)}</button>
</div>
</Modal>
}
}
/// Whether a file name is one the import takes, and as what.
pub fn import_kind(name: &str) -> Option<PimCollectionKind> {
let lower = name.to_ascii_lowercase();
if lower.ends_with(".ics") || lower.ends_with(".ical") || lower.ends_with(".ifb") {
Some(PimCollectionKind::Calendar)
} else if lower.ends_with(".vcf") || lower.ends_with(".vcard") {
Some(PimCollectionKind::Addressbook)
} else {
None
}
}
/// Import a file of a root into one of the user's calendars or address
/// books, after a look at what it holds.
#[component]
pub fn PimImportDialog(
root_id: i64,
path: String,
name: String,
on_close: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let kind = import_kind(&name).unwrap_or(PimCollectionKind::Calendar);
let (targets, set_targets) = signal(Option::<Vec<PimCollectionInfo>>::None);
let (preview, set_preview) = signal(Option::<Result<PimPreview, String>>::None);
let (target, set_target) = signal(Option::<i64>::None);
let (busy, set_busy) = signal(false);
let (result, set_result) = signal(Option::<PimImportResult>::None);
{
let path = path.clone();
spawn_local(async move {
let tz = browser_tz();
match api::pim_preview(root_id, &path, &tz).await {
Ok(p) => set_preview.set(Some(Ok(p))),
Err(e) => set_preview.set(Some(Err(e.to_string()))),
}
match api::pim_collections().await {
Ok(list) => {
let list: Vec<_> = list
.into_iter()
.filter(|c| c.kind == kind && writable(c))
.collect();
set_target.set(list.first().map(|c| c.id));
set_targets.set(Some(list));
}
Err(e) => {
show_error(toast, e.to_string());
set_targets.set(Some(Vec::new()));
}
}
});
}
let run = move |_| {
let (Some(id), false) = (target.get(), busy.get()) else {
return;
};
set_busy.set(true);
let path = path.clone();
spawn_local(async move {
match api::pim_import(id, root_id, &path).await {
Ok(r) => set_result.set(Some(r)),
Err(e) => show_error(toast, e.to_string()),
}
set_busy.set(false);
});
};
let summary = move |p: &PimPreview| {
let mut parts = vec![match p.kind {
PimCollectionKind::Calendar => i18n::t_fmt(k::PIM_FILE_EVENTS, &p.total.to_string()),
PimCollectionKind::Addressbook => {
i18n::t_fmt(k::PIM_FILE_CONTACTS, &p.total.to_string())
}
}];
if p.invalid > 0 {
parts.push(i18n::t_fmt(k::PIM_FILE_INVALID, &p.invalid.to_string()));
}
parts.join(" · ")
};
let sample = move |p: &PimPreview| {
let names: Vec<String> = match p.kind {
PimCollectionKind::Calendar => p
.events
.iter()
.take(5)
.map(|e| {
let when = e.start.as_deref().map(|s| match e.all_day {
true => s[..10.min(s.len())].to_string(),
false => format_date(s),
});
let what = e.summary.clone().unwrap_or_else(|| "—".to_string());
match when {
Some(w) => format!("{w} · {what}"),
None => what,
}
})
.collect(),
PimCollectionKind::Addressbook => p
.contacts
.iter()
.take(5)
.map(|c| match &c.email {
Some(e) => format!("{} · {e}", c.full_name),
None => c.full_name.clone(),
})
.collect(),
};
let shown = names.len();
let rest = p.total.saturating_sub(p.invalid).saturating_sub(shown);
view! {
<ul class="pim-sample">
{names.into_iter().map(|n| view! { <li>{n}</li> }).collect::<Vec<_>>()}
{(rest > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &rest.to_string())}</li> })}
</ul>
}
};
view! {
<Modal class="pim-modal" on_close=on_close>
<h2 class="modal-title">{i18n::t_fmt(k::PIM_IMPORT_TITLE, &name)}</h2>
{move || match result.get() {
Some(r) => {
let skipped = r.skipped.clone();
let more = r.skipped_total.saturating_sub(skipped.len());
view! {
<p class="modal-message">
{i18n::t_fmt2(k::PIM_IMPORT_DONE, &r.created.to_string(), &r.updated.to_string())}
</p>
{(r.skipped_total > 0).then(|| view! {
<p class="modal-message">{i18n::t_fmt(k::PIM_IMPORT_SKIPPED, &r.skipped_total.to_string())}</p>
<ul class="pim-sample">
{skipped
.into_iter()
.map(|s| view! {
<li>{format!("{} · {}", s.uid.unwrap_or_else(|| "—".to_string()), s.reason)}</li>
})
.collect::<Vec<_>>()}
{(more > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &more.to_string())}</li> })}
</ul>
})}
<div class="modal-actions">
<button class="btn btn-primary" on:click=move |_| on_close.run(())>{i18n::tr(k::DONE)}</button>
</div>
}
.into_any()
}
None => view! {
{move || match preview.get() {
None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <p class="form-error">{e}</p> }.into_any(),
Some(Ok(p)) => view! {
<p class="modal-message">{summary(&p)}</p>
{sample(&p)}
}
.into_any(),
}}
{move || match targets.get() {
None => None,
Some(list) if list.is_empty() => Some(view! {
<p class="muted pim-hint">{i18n::tr(k::PIM_NO_TARGET)}</p>
}.into_any()),
Some(list) => Some(view! {
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_IMPORT_INTO)}</span>
<select
class="field-input"
on:change=move |ev| set_target.set(event_target_value(&ev).parse().ok())
>
{list
.into_iter()
.map(|c| {
let label = match c.mode {
Some(_) => format!("{} ({})", display_name(&c), c.owner),
None => display_name(&c),
};
view! { <option value=c.id.to_string()>{label}</option> }
})
.collect::<Vec<_>>()}
</select>
</label>
}.into_any()),
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CANCEL)}</button>
<button
class="btn btn-primary"
disabled=move || {
busy.get()
|| target.get().is_none()
|| !matches!(preview.get(), Some(Ok(ref p)) if p.total > p.invalid)
}
on:click=run.clone()
>
{move || if busy.get() { i18n::t(k::PIM_IMPORTING) } else { i18n::t(k::PIM_IMPORT_BTN) }}
</button>
</div>
}
.into_any(),
}}
</Modal>
}
}
▾Mweb/src/views/shares.rs
@@ -7,7 +7,7 @@ use wasm_bindgen_futures::spawn_local;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use crate::api::{self, AdminShare, Me, ShareInfo, UserInfo};
use crate::api::{self, AdminPimLink, AdminShare, Me, ShareInfo, UserInfo};
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
@@ -37,7 +37,7 @@ fn ms_to_rfc3339(ms: f64) -> Option<String> {
/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
/// "never". Presets are computed from "now"; "custom" parses the
/// datetime-local value as the browser's local time.
fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
pub(crate) fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
let now = js_sys::Date::now();
let h = 60.0 * 60.0 * 1000.0;
let target_ms: Option<f64> = match choice {
@@ -708,6 +708,8 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
}
}}
{move || admin().then(|| view! { <AdminFeeds/> })}
// Delete-all confirmation, for one account or for everything.
{move || {
let p = confirm.get()?;
@@ -731,3 +733,103 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
</div>
}
}
/// Admin only: every public calendar and address book feed on the server.
/// Owners manage their own in the calendar and contact views.
#[component]
fn AdminFeeds() -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (feeds, set_feeds) = signal(Option::<Vec<AdminPimLink>>::None);
let load = move || {
spawn_local(async move {
match api::admin_pim_links().await {
Ok(v) => set_feeds.set(Some(v)),
Err(e) => show_error(toast, e.to_string()),
}
});
};
load();
let row = move |f: AdminPimLink| {
let url = crate::router::server_url(&f.link.path);
let copy = url.clone();
let id = f.link.id;
let mut meta = vec![
f.owner_name.clone(),
if f.link.busy_only {
i18n::t(i18n::k::PIM_LINK_BUSY).to_string()
} else {
i18n::t(i18n::k::PIM_LINK_FULL).to_string()
},
];
if let Some(e) = &f.link.expires_at {
meta.push(i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e)));
}
meta.push(i18n::t_fmt(
i18n::k::META_CREATED,
&format_date(&f.link.created_at),
));
if f.link.has_password {
meta.push(i18n::t(i18n::k::SHARE_PROTECTED).to_string());
}
if !f.owner_active {
meta.push(i18n::t(i18n::k::SHARE_OWNER_DISABLED).to_string());
}
let glyph = match f.kind {
api::PimCollectionKind::Calendar => IconName::Calendar,
api::PimCollectionKind::Addressbook => IconName::Contacts,
};
view! {
<div class="share-item">
<div class="share-item-main">
<div class="share-item-name">
{icon_svg(glyph, "ic-row")}
<span>{f.collection_name.clone()}</span>
</div>
<div class="share-item-meta muted">{meta.join(" · ")}</div>
</div>
<div class="share-item-actions">
<button
class="btn btn-sm"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(i18n::k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
<button
class="btn btn-sm btn-danger"
on:click=move |_| {
spawn_local(async move {
match api::admin_delete_pim_link(id).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::PIM_LINK_REVOKED).to_string());
load();
}
Err(e) => show_error(toast, e.to_string()),
}
});
}
>
{icon_svg(IconName::Delete, "ic-btn")}
{i18n::t(i18n::k::DELETE)}
</button>
</div>
</div>
}
};
view! {
<div class="view-header feeds-header">
<h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_FEEDS)}</h3>
<p class="muted view-hint">{i18n::tr(i18n::k::PIM_FEEDS_HINT)}</p>
</div>
{move || match feeds.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::PIM_NO_FEEDS)}</p> }.into_any()
}
Some(list) => view! {
<div class="share-list">{list.into_iter().map(row).collect::<Vec<_>>()}</div>
}
.into_any(),
}}
}
}
▾Mweb/src/views/shell.rs
@@ -4,13 +4,15 @@ use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::PimCollectionKind;
use crate::api::{self, Me};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, provide_toast, show_error};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate};
use crate::views::admin::{SettingsView, UsersView};
use crate::views::admin::{RoomsPanel, SettingsView, UsersView};
use crate::views::browser::{Browser, effective_root};
use crate::views::pim::PimView;
use crate::views::search::SearchView;
use crate::views::shares::SharesView;
use crate::views::uploads::UploadsView;
@@ -246,6 +248,23 @@ pub fn ShellView(
.collect::<Vec<_>>())
}}
</div>
<div class="nav-section">
<div class="nav-section-label">{i18n::tr(i18n::k::PIM_NAV)}</div>
{[
(Section::Calendar, IconName::Calendar, i18n::k::PIM_CALENDAR),
(Section::Contacts, IconName::Contacts, i18n::k::PIM_CONTACTS),
]
.map(|(sec, icon, label)| view! {
<button
class="nav-tab"
class:active=move || section.get() == sec
on:click=move |_| open_section(sec)
>
<Icon name=icon class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(label)}</span>
</button>
})}
</div>
<div class="nav-section">
<div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
{[
@@ -304,9 +323,13 @@ pub fn ShellView(
<main class="content" class:file-open=move || file_view.get().is_some()>
{move || {
match section.get() {
Section::Users if admin.get() => {
view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_any()
Section::Users if admin.get() => view! {
<div class="admin-stack">
<UsersView me=me set_me=set_me set_dialog=set_dialog/>
<RoomsPanel/>
</div>
}
.into_any(),
// Users without admin (e.g. permissions changed)
// falls back to the files view.
Section::Files | Section::Users => view! {
@@ -341,6 +364,14 @@ pub fn ShellView(
}
.into_any(),
Section::Uploads => view! { <UploadsView/> }.into_any(),
Section::Calendar => view! {
<PimView kind=PimCollectionKind::Calendar me=me set_dialog=set_dialog/>
}
.into_any(),
Section::Contacts => view! {
<PimView kind=PimCollectionKind::Addressbook me=me set_dialog=set_dialog/>
}
.into_any(),
}
}}
{file_content}