Calendar and contacts web UI, part 2: month view, agenda, contacts

- Month grid (six weeks, locale week start, multi-day bars, "+N more",
  day list below; dots on phones), agenda, invitations with replies,
  event details with attendees, repetition and linkified description
- Contacts: debounced search, list with photo or initials, details;
  list and details take turns on phones
- URL keeps view, month, open event, contact search and open contact
- Import is an upload in the collection dialog; the file-browser import,
  the file preview endpoint and root-file import source are removed
- Export is download only; saving into a folder is removed
- i18n: current() tracks only in reactive scopes; toast timer reads
  untracked; selects in PIM views styled for both themes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit67f31df3beb9556f70ff170a7247a071cd617385
Parent3108d1f
23 files changed, 3263 insertions(+), 826 deletions(-)
▾MREADME.md
@@ -276,17 +276,39 @@ never fetched.
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).
color, description, "show as free", sharing, public links, import,
download 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 Calendar page has three views:
- **Month**: six weeks, starting on Sunday in English and on Monday in
German and French. Events that last a day or more are bars across the
days, timed events show their start. A day with more events than fit
shows "+N more". A click on a day lists its events below the grid. On a
phone the days show colored dots instead, and the list below the grid
shows the chosen day. PageUp and PageDown turn the month.
- **Agenda**: the next 30 days, day by day, 30 more per click.
- **Invitations**: the invitations you have not answered, with Accept,
Maybe and Decline. The count shows on the tab.
A click on an event opens its details: time, repetition, place, organizer,
attendees with their answers and the description. For an invitation it
also has the three answers; for one date of a series you pick that date or
the whole series. The answer goes to the organizer like one from an app.
Times show in the browser's time zone. The URL holds the view, the month
and the open event, for example `#/calendar/month/2026-10?open=...`.
The Contacts page searches all visible address books and shows one contact
at a time, with its photo, addresses and notes. On a phone the list and
the contact take turns.
The views 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) and `/api/pim/invitations` (unanswered
invitations; `POST` answers one like an app would).
### Sharing
@@ -356,8 +378,7 @@ The answer holds the link's path. Admins see every link with its owner at
`POST /api/pim/collections/<id>/import` imports an `.ics` file into a
calendar, or a `.vcf` file into an address book. The body is the file
itself, or JSON `{"root_id": <id>, "path": "<path>"}` for a file in one of
your folders. You need write access: your own collection, or a `rw` or
itself. You need write access: your own collection, or a `rw` or
`rw+schedule` loan.
- The file is split into one entry per UID. Changed instances of a
@@ -376,15 +397,13 @@ your folders. You need write access: your own collection, or a `rw` or
a zone then follow the calendar's own time zone.
`GET /api/pim/collections/<id>/export` downloads a calendar or address
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, 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.
book as one file, with every event in full, private ones included. Lent
collections can 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, each collection's dialog has "Import file…" and
"Download".
### Invitations
▾Mapi-types/src/lib.rs
@@ -84,14 +84,11 @@ pub const SHARES_SUFFIX: &str = "/shares";
/// `{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`: the public feeds of an own
/// collection (`GET`, `POST`); `DELETE` on `.../{link_id}` below it.
pub const LINKS_SUFFIX: &str = "/links";
/// `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}`: an `.ics` or `.vcf` body, or
/// a JSON [`PimRootFile`] naming a file in a root.
/// `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}`: an `.ics` or `.vcf` body.
pub const IMPORT_SUFFIX: &str = "/import";
/// `{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: `GET` downloads the collection as
/// one file; `POST` with a [`PimRootFile`] saves it into a root.
/// `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: the collection as one file.
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.
/// `GET`: the system address book as one file. It has no collection id.
pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`: one event or contact
/// as [`PimObjectDetail`]. `{...}/{name}{PHOTO_SUFFIX}`: a contact's photo as
@@ -107,9 +104,6 @@ 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";
@@ -811,34 +805,6 @@ pub struct PimReply {
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}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimShareInfo {
@@ -895,13 +861,6 @@ pub struct CreatePimLink {
pub password: Option<String>,
}
/// A file in one of the signed-in user's roots.
#[derive(Serialize, Deserialize)]
pub struct PimRootFile {
pub root_id: i64,
pub path: String,
}
/// The answer to an import.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimImportResult {
▾Mpimdav/src/view.rs
@@ -178,31 +178,6 @@ pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option<DateTime<Utc>> {
})
}
/// 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>,
▾Mserver/src/api/files.rs
@@ -1262,7 +1262,7 @@ pub(crate) fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, Api
})
}
pub(crate) fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
let root = find_root(roots, root_id)?;
if !root.mode.is_writable() {
return Err(ApiError::localized(
▾Mserver/src/api/mod.rs
@@ -5,7 +5,7 @@ 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_CONTACTS, PIM_INSTANCES, PIM_INVITATIONS, PIM_PREVIEW, PIM_SYSTEM_EXPORT, SEARCH, SHARE,
PIM_CONTACTS, PIM_INSTANCES, PIM_INVITATIONS, PIM_SYSTEM_EXPORT, SEARCH, SHARE,
SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
@@ -203,20 +203,13 @@ pub fn router(state: Arc<AppState>) -> Router {
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))
.route(&pim_link, delete(pim_api::delete_link))
.route(&pim_import, post(pim_api::import))
.route(
&pim_export,
get(pim_api::export).post(pim_api::export_to_root),
)
.route(
PIM_SYSTEM_EXPORT,
get(pim_api::export_system).post(pim_api::export_system_to_root),
)
.route(&pim_export, get(pim_api::export))
.route(PIM_SYSTEM_EXPORT, get(pim_api::export_system))
.route(&pim_photo, get(pim_api::photo))
.route(ADMIN_PIM_LINKS, get(admin::list_pim_links))
.route(&admin_pim_link, delete(admin::delete_pim_link))
▾Mserver/src/api/pim_api.rs
@@ -19,8 +19,8 @@ use std::sync::Arc;
use api_types::{
CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo,
PimCollectionKind, PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode,
PimSkipped, UpdatePimCollection,
PimCollectionKind, PimImportResult, PimLinkInfo, PimShareInfo, PimShareMode, PimSkipped,
UpdatePimCollection,
};
use axum::Json;
use axum::body::Body;
@@ -32,9 +32,9 @@ use pimdav::bundle::{self, Detail};
use pimdav::{contact, object};
use sha2::{Digest, Sha256};
use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
use crate::api::common::{SessionUser, hash_password, validate_password};
use crate::api::dav::challenge;
use crate::api::files::{disposition, find_root, require_rw_root};
use crate::api::files::disposition;
use crate::api::pim::{
BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, OUTBOX, SHARED_PREFIX,
collection_href, delete_own, etag_of, generated, members_of,
@@ -43,7 +43,6 @@ use crate::api::pim_schedule::{self, Directory, object_name};
use crate::auth;
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace};
use crate::error::{ApiError, AppState};
use crate::fs;
/// The largest file an import reads.
const MAX_IMPORT: usize = 20 * 1024 * 1024;
@@ -681,18 +680,6 @@ pub async fn export(
Ok(download(kind, &name_of(&col), body))
}
/// POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}: a new file in a writable root.
pub async fn export_to_root(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
Json(target): Json<PimRootFile>,
) -> Result<Json<OkResp>, ApiError> {
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
}
/// GET {PIM_SYSTEM_EXPORT}
pub async fn export_system(
State(state): State<Arc<AppState>>,
@@ -702,16 +689,6 @@ pub async fn export_system(
Ok(download(PimKind::AddressBook, &name_of(&col), body))
}
/// POST {PIM_SYSTEM_EXPORT}: a new file in a writable root.
pub async fn export_system_to_root(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Json(target): Json<PimRootFile>,
) -> Result<Json<OkResp>, ApiError> {
let (_, body) = system_cards(&state).await?;
save(&state, &auth, target, body).await
}
async fn system_cards(state: &AppState) -> Result<(PimCollection, String), ApiError> {
let (col, members) = crate::api::pim::directory(state).await?;
let texts: Vec<String> = members
@@ -734,28 +711,6 @@ fn download(kind: PimKind, name: &str, body: String) -> Response {
.into_response()
}
async fn save(
state: &AppState,
auth: &SessionUser,
target: PimRootFile,
body: String,
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, target.root_id)?;
let (server_root, root_path) = (state.root.clone(), root.path.clone());
blocking(move || {
fs::create_file(&server_root, &root_path, &target.path)?;
fs::save_file(
&server_root,
&root_path,
&target.path,
body.as_bytes(),
None,
)
})
.await?;
Ok(Json(OkResp {}))
}
/// POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}
///
/// Each object goes through the checks of a PUT and is skipped where a PUT
@@ -765,31 +720,16 @@ pub async fn import(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
headers: HeaderMap,
body: Body,
) -> Result<Json<PimImportResult>, ApiError> {
let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
if !writable {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let too_large = || ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large");
let json = headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("application/json"));
let data = if json {
let raw = axum::body::to_bytes(body, 64 * 1024)
.await
.map_err(|_| too_large())?;
let file: PimRootFile = serde_json::from_slice(&raw)
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid JSON body"))?;
read_root_file(&state, &auth, file).await?
} else {
axum::body::to_bytes(body, MAX_IMPORT)
.await
.map_err(|_| too_large())?
.to_vec()
};
let data = axum::body::to_bytes(body, MAX_IMPORT)
.await
.map_err(|_| ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large"))?
.to_vec();
// Old phone exports are often Latin-1.
let text = String::from_utf8(data)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect());
@@ -895,27 +835,3 @@ pub async fn import(
result.updated = updated;
Ok(Json(result))
}
pub(super) async fn read_root_file(
state: &AppState,
auth: &SessionUser,
file: PimRootFile,
) -> Result<Vec<u8>, ApiError> {
let root = find_root(&auth.roots, file.root_id)?;
let (server_root, root_path) = (state.root.clone(), root.path.clone());
blocking(move || {
let full = fs::resolve_path(&server_root, &root_path, &file.path)?;
let meta = std::fs::metadata(&full)?;
if meta.is_dir() {
return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
}
if meta.len() > MAX_IMPORT as u64 {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"file too large",
));
}
Ok(std::fs::read(&full)?)
})
.await
}
▾Mserver/src/api/pim_views.rs
@@ -11,10 +11,9 @@ 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,
OBJECTS_SUFFIX, OkResp, PHOTO_SUFFIX, PIM_COLLECTIONS, PimAttendee, PimContact,
PimContactDetail, PimEventDetail, PimInstance, PimInstances, PimInvitation, PimLabeled,
PimObjectDetail, PimPerson, PimReply, PimShareMode,
};
use axum::Json;
use axum::extract::{Path as AxumPath, Query, State};
@@ -26,13 +25,11 @@ 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_api::reachable;
use crate::api::pim_schedule::{self, Directory, Writer};
use crate::db::{PimKind, PimObject, PimOp};
use crate::error::{ApiError, AppState};
@@ -41,8 +38,6 @@ use crate::error::{ApiError, AppState};
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;
@@ -534,86 +529,3 @@ pub async fn reply(
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))
}
▾Mserver/tests/api_pim_io.rs
@@ -18,7 +18,7 @@ impl Io {
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, "alice", PW, &[]).await;
create_user(&admin, "bob", PW, &[]).await;
let alice = login(&env, "alice", PW).await;
Io { env, alice }
@@ -55,12 +55,6 @@ impl Io {
.unwrap()
}
async fn root_id(&self, index: usize) -> i64 {
self.alice.get("/api/auth/me").await.json()["roots"][index]["id"]
.as_i64()
.unwrap()
}
async fn link(&self, id: i64, body: Value) -> String {
let r = self
.alice
@@ -340,54 +334,21 @@ async fn import_sends_nothing_and_keeps_uniqueness() {
}
#[tokio::test]
async fn root_files_and_round_trip() {
async fn export_round_trip() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let (docs, src) = (io.root_id(0).await, io.root_id(1).await);
std::fs::write(io.env.file("docs/in.ics"), event("r1", "From a file", "")).unwrap();
std::fs::write(io.env.file("src/ro.ics"), event("r2", "Read-only root", "")).unwrap();
for (root, path) in [(docs, "in.ics"), (src, "ro.ics")] {
let r = io
.alice
.post_json(
&format!("/api/pim/collections/{cal}/import"),
&json!({ "root_id": root, "path": path }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["created"], 1);
}
let r = io
.alice
.post_json(
&format!("/api/pim/collections/{cal}/import"),
&json!({ "root_id": docs, "path": "../../etc/passwd" }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
io.import(cal, &event("r1", "One", "")).await;
io.import(cal, &event("r2", "Two", "")).await;
let r = io
.alice
.get(&format!("/api/pim/collections/{cal}/export"))
.await;
let url = format!("/api/pim/collections/{cal}/export");
let r = io.alice.get(&url).await;
assert_eq!(r.status, StatusCode::OK);
let disposition = r.header("content-disposition").unwrap();
assert!(disposition.starts_with("attachment;") && disposition.contains(".ics"));
let exported = r.text();
let url = format!("/api/pim/collections/{cal}/export");
let into = |root: i64| json!({ "root_id": root, "path": "out.ics" });
let r = io.alice.post_json(&url, &into(docs)).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
std::fs::read_to_string(io.env.file("docs/out.ics")).unwrap(),
exported
);
let r = io.alice.post_json(&url, &into(docs)).await;
assert_eq!(r.status, StatusCode::CONFLICT);
let r = io.alice.post_json(&url, &into(src)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Download only: there is no saving into a folder.
let r = io.alice.post_json(&url, &json!({})).await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
// The export imports back as the same objects.
io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/copy/", "")
@@ -409,13 +370,5 @@ async fn root_files_and_round_trip() {
let r = io.alice.get("/api/pim/system/export").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("content-disposition").unwrap().contains(".vcf"));
let people = r.text();
assert!(people.contains("FN:alice"), "{people}");
let into = json!({ "root_id": docs, "path": "people.vcf" });
let r = io.alice.post_json("/api/pim/system/export", &into).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
std::fs::read_to_string(io.env.file("docs/people.vcf")).unwrap(),
people
);
assert!(r.text().contains("FN:alice"));
}
▾Mserver/tests/api_pim_ui.rs
@@ -18,7 +18,7 @@ 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, "alice", PW, &[]).await;
create_user(&admin, "bob", PW, &[]).await;
let alice = login(&env, "alice", PW).await;
let bob = login(&env, "bob", PW).await;
@@ -61,12 +61,6 @@ impl Ui {
.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 {
@@ -579,51 +573,3 @@ async fn invitations_are_listed_and_answered() {
.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/Cargo.toml
@@ -57,6 +57,8 @@ web-sys = { version = "0.3", features = [
"RequestInit",
"RequestMode",
"Response",
"ScrollIntoViewOptions",
"ScrollLogicalPosition",
"Storage",
"SubmitEvent",
"UrlSearchParams",
▾Mweb/app.css
@@ -3220,3 +3220,783 @@ body:has(.page-busy) {
max-width: 100%;
}
}
/* Mirrored chevron: the icon set has only the right-pointing one. */
.flip {
transform: scaleX(-1);
}
/* Selects in the calendar and contacts views and their dialogs: the same
height and colors as the inputs beside them, in both themes. */
.pim-modal select,
.pim-view select {
appearance: none;
padding: 10px 34px 10px 12px;
border: 1px solid var(--border);
background-color: var(--bg);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 11px center;
background-size: 12px 12px;
color: var(--text);
font: inherit;
font-size: 14px;
}
.pim-modal select:focus,
.pim-view select:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.pim-link-form .pim-inline input {
padding: 10px 12px;
}
.pim-file-input {
display: none;
}
.pim-main {
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- Calendar ---------------------------------------------------- */
.cal {
display: flex;
flex-direction: column;
min-width: 0;
}
.cal-pad {
padding: 12px 14px;
}
.cal-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.cal-nav {
display: flex;
align-items: center;
gap: 4px;
}
.cal-title {
flex: 1;
min-width: 0;
margin: 0;
font-size: 17px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-views {
display: flex;
}
.cal-view-btn {
padding: 6px 12px;
font-size: 13px;
margin-left: -1px;
}
.cal-view-btn.active {
background: var(--accent);
border-color: var(--accent-border);
color: #fff;
position: relative;
}
.cal-badge {
min-width: 18px;
padding: 0 5px;
font-size: 11px;
line-height: 18px;
background: var(--danger);
color: #fff;
text-align: center;
}
.cal-truncated {
margin: 0;
padding: 8px 12px;
font-size: 13px;
color: var(--danger);
border-bottom: 1px solid var(--border);
}
.cal-head {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
border-bottom: 1px solid var(--border);
}
.cal-head-day {
padding: 6px 8px;
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: 26px repeat(var(--lines), 22px) 6px;
border-bottom: 1px solid var(--border);
}
.cal-cell {
grid-row: 1 / -1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
min-width: 0;
padding: 4px 6px;
border: 0;
border-right: 1px solid var(--border);
background: none;
color: var(--text);
font: inherit;
cursor: pointer;
}
.cal-cell:nth-child(7) {
border-right: 0;
}
.cal-cell:hover {
background: var(--bg);
}
.cal-cell.cal-out .cal-num {
color: var(--muted);
}
.cal-cell.cal-sel {
background: var(--bg);
box-shadow: inset 0 0 0 2px var(--accent);
}
.cal-num {
font-size: 13px;
line-height: 18px;
min-width: 20px;
text-align: center;
}
.cal-today .cal-num {
background: var(--accent);
color: #fff;
font-weight: 600;
}
/* Phone only: the colors of a day's events instead of their titles. */
.cal-dots {
display: none;
gap: 3px;
}
.cal-dot {
display: inline-block;
flex: none;
width: 8px;
height: 8px;
background: var(--c, var(--accent));
}
.cal-item {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
min-width: 0;
margin: 1px 2px;
padding: 0 6px;
border: 0;
font-size: 12px;
line-height: 20px;
text-align: left;
cursor: pointer;
overflow: hidden;
}
.cal-bar {
background: var(--c);
color: #fff;
text-shadow: 0 0 2px rgb(0 0 0 / 40%);
}
.cal-bar.cal-cont-l {
margin-left: 0;
}
.cal-bar.cal-cont-r {
margin-right: 0;
}
.cal-chip {
background: none;
color: var(--text);
}
.cal-chip:hover {
background: var(--bg);
}
.cal-time {
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.cal-item-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-cancelled .cal-item-title,
.cal-cancelled .cal-row-title,
.cal-detail-title.cal-cancelled {
text-decoration: line-through;
}
.cal-more {
position: relative;
z-index: 1;
justify-content: flex-start;
margin: 1px 2px;
padding: 0 6px;
border: 0;
background: none;
color: var(--muted);
font-size: 12px;
line-height: 20px;
}
.cal-more:hover {
color: var(--text);
text-decoration: underline;
}
.cal-day {
border-top: 1px solid var(--border);
}
.cal-day-title {
margin: 0;
padding: 12px 14px 6px;
font-size: 14px;
}
.cal-day-today {
color: var(--accent);
}
.cal-rows {
display: flex;
flex-direction: column;
padding-bottom: 8px;
}
.cal-row {
display: flex;
align-items: baseline;
justify-content: flex-start;
gap: 10px;
padding: 7px 14px;
border: 0;
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
min-width: 0;
}
.cal-row:hover {
background: var(--bg);
}
.cal-row .cal-dot {
align-self: center;
}
.cal-row-time {
flex: none;
width: 120px;
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.cal-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cal-row-title {
font-size: 14px;
overflow-wrap: anywhere;
}
.cal-row-meta {
font-size: 12.5px;
color: var(--muted);
overflow-wrap: anywhere;
}
.cal-agenda-day + .cal-agenda-day {
border-top: 1px solid var(--border);
}
.cal-inv {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.cal-inv-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
padding: 0;
border: 0;
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.cal-inv-title {
font-weight: 600;
overflow-wrap: anywhere;
}
.cal-inv-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cal-detail-title {
overflow-wrap: anywhere;
}
.cal-flag {
display: inline-block;
margin: 0 0 12px;
padding: 2px 8px;
font-size: 12px;
border: 1px solid var(--danger);
color: var(--danger);
}
.cal-facts {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px 16px;
margin: 0 0 16px;
font-size: 14px;
}
.cal-facts dt {
color: var(--muted);
}
.cal-facts dd {
margin: 0;
overflow-wrap: anywhere;
}
.cal-pre {
white-space: pre-wrap;
}
.cal-desc {
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--bg);
font-size: 14px;
overflow-wrap: anywhere;
max-height: 40vh;
overflow-y: auto;
}
.cal-attendees {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.cal-attendees li {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.cal-att-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ps-badge {
margin-left: auto;
flex: none;
padding: 1px 7px;
font-size: 11.5px;
border: 1px solid var(--border);
color: var(--muted);
}
.ps-accepted {
border-color: #16a34a;
color: #16a34a;
}
.ps-declined {
border-color: var(--danger);
color: var(--danger);
}
.ps-tentative {
border-color: #d97706;
color: #d97706;
}
.cal-reply {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.cal-scope {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.cal-scope input[type="radio"] {
width: auto;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
/* ---- Contacts ---------------------------------------------------- */
.ct {
display: flex;
min-height: 0;
flex: 1;
}
.ct-list {
flex: none;
width: 320px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
min-height: 0;
}
.ct-search {
position: relative;
padding: 10px;
border-bottom: 1px solid var(--border);
}
.ct-search input {
padding-left: 34px;
}
.ct-search-icon {
position: absolute;
left: 20px;
top: 50%;
width: 16px;
height: 16px;
transform: translateY(-50%);
color: var(--muted);
pointer-events: none;
}
.ct-rows {
display: flex;
flex-direction: column;
overflow-y: auto;
max-height: calc(100vh - var(--topbar-h) - 120px);
}
.ct-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 8px 12px;
border: 0;
border-bottom: 1px solid var(--border);
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
min-width: 0;
}
.ct-row:hover {
background: var(--bg);
}
.ct-row.ct-sel {
background: var(--bg);
box-shadow: inset 3px 0 0 var(--accent);
}
.ct-avatar {
flex: none;
width: 36px;
height: 36px;
object-fit: cover;
background: var(--bg);
}
.ct-initials {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.ct-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--bg);
}
.ct-avatar-lg {
width: 72px;
height: 72px;
font-size: 22px;
}
.ct-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ct-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
min-width: 0;
}
.ct-name-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ct-meta {
font-size: 12.5px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ct-detail {
flex: 1;
min-width: 0;
padding: 16px 20px;
overflow-y: auto;
}
.ct-back {
display: none;
margin-bottom: 12px;
}
.ct-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 18px;
min-width: 0;
}
.ct-head-text {
min-width: 0;
}
.ct-head-text p {
margin: 4px 0 0;
overflow-wrap: anywhere;
}
.ct-title {
margin: 0;
font-size: 20px;
overflow-wrap: anywhere;
}
.ct-value {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0 8px;
}
.ct-value + .ct-value {
margin-top: 4px;
}
.ct-label {
font-size: 12px;
}
@media (max-width: 860px) {
.ct-list {
width: 260px;
}
.cal-row-time {
width: 96px;
}
}
@media (max-width: 640px) {
.content:has(.pim-view) {
padding: 12px;
}
.cal-toolbar {
padding: 8px;
}
.cal-title {
order: -1;
flex-basis: 100%;
}
.cal-views {
margin-left: auto;
}
.cal-view-btn {
padding: 6px 9px;
}
/* Phones get compact day cells with dots; the day list below the grid
shows the chosen day's events. */
.cal-week {
grid-template-rows: 46px;
}
.cal-item,
.cal-more {
display: none;
}
.cal-dots {
display: flex;
}
.cal-cell {
align-items: center;
padding: 4px 2px;
}
.cal-head-day {
padding: 6px 2px;
text-align: center;
}
.cal-inv {
flex-direction: column;
align-items: stretch;
}
.cal-row-time {
width: 80px;
}
.cal-facts {
grid-template-columns: minmax(0, 1fr);
gap: 2px;
}
.cal-facts dd {
margin-bottom: 8px;
}
/* List and details take turns. */
.ct-list {
width: auto;
flex: 1;
border-right: 0;
}
.ct-rows {
max-height: none;
}
.ct-detail,
.ct-has-open .ct-list {
display: none;
}
.ct-has-open .ct-detail {
display: block;
padding: 12px;
}
.ct-back {
display: inline-flex;
}
}
▾Mweb/src/api.rs
@@ -25,12 +25,14 @@ use api_types::{
};
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,
EXPORT_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, P_FROM, P_RECURRENCE_ID, P_TO,
P_TZ, PHOTO_SUFFIX, PIM_COLLECTIONS, PIM_CONTACTS, PIM_INSTANCES, PIM_INVITATIONS,
SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
};
pub use api_types::{
AdminPimLink, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview,
PimShareInfo, PimShareMode, RoomInfo, RoomKind,
AdminPimLink, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail,
PimEventDetail, PimImportResult, PimInstance, PimInstances, PimInvitation, PimLinkInfo,
PimObjectDetail, PimReply, PimShareInfo, PimShareMode, RoomInfo, RoomKind,
};
pub use api_types::{
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
@@ -1271,20 +1273,15 @@ pub fn pim_delete_link(
)
}
/// 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(),
}),
)
/// Imports an `.ics` or `.vcf` file into a collection.
pub async fn pim_import(id: i64, file: web_sys::File) -> Result<PimImportResult, ApiError> {
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts.set_body(&file.into());
let url = format!("{PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}");
let resp = fetch_checked(&url, &opts, "import failed").await?;
read_json(&resp).await
}
/// Downloads a collection as one `.ics` or `.vcf` file.
@@ -1292,38 +1289,76 @@ 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(
/// The instances of the readable calendars between `from` and `to` (RFC
/// 3339), with dates and floating times read in `tz`.
pub fn pim_instances(
from: &str,
to: &str,
tz: &str,
) -> impl std::future::Future<Output = Result<PimInstances, ApiError>> {
request(
"GET",
format!(
"{PIM_INSTANCES}?{P_FROM}={}&{P_TO}={}&{P_TZ}={}",
enc(from),
enc(to),
enc(tz)
),
None::<()>,
)
}
/// One event or contact. `recurrence_id` picks an instance of a series.
pub fn pim_object(
id: i64,
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
name: &str,
recurrence_id: Option<&str>,
tz: &str,
) -> impl std::future::Future<Output = Result<PimObjectDetail, ApiError>> {
let mut url = format!(
"{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}?{P_TZ}={}",
enc(name),
enc(tz)
);
if let Some(rid) = recurrence_id {
url.push_str(&format!("&{P_RECURRENCE_ID}={}", enc(rid)));
}
request("GET", url, None::<()>)
}
/// A contact's photo as a small WebP.
pub fn pim_photo_url(id: i64, name: &str) -> String {
format!(
"{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
enc(name)
)
}
pub fn pim_contacts(
q: &str,
) -> impl std::future::Future<Output = Result<Vec<PimContact>, ApiError>> {
request(
"POST",
pim_export_url(id),
Some(PimRootFile {
root_id,
path: path.to_string(),
}),
"GET",
format!("{PIM_CONTACTS}?{P_Q}={}", enc(q)),
None::<()>,
)
}
pub fn pim_preview(
root_id: i64,
path: &str,
pub fn pim_invitations(
tz: &str,
) -> impl std::future::Future<Output = Result<PimPreview, ApiError>> {
) -> impl std::future::Future<Output = Result<Vec<PimInvitation>, ApiError>> {
request(
"GET",
format!(
"{PIM_PREVIEW}?{P_ROOT}={root_id}&{P_PATH}={}&{P_TZ}={}",
enc(path),
enc(tz)
),
format!("{PIM_INVITATIONS}?{P_TZ}={}", enc(tz)),
None::<()>,
)
}
/// Answers an invitation as the calendar owner.
pub fn pim_reply(body: PimReply) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", PIM_INVITATIONS.to_string(), Some(body))
}
pub fn list_rooms() -> impl std::future::Future<Output = Result<Vec<RoomInfo>, ApiError>> {
request("GET", ADMIN_ROOMS.to_string(), None::<()>)
}
▾Mweb/src/components/toast.rs
@@ -31,7 +31,7 @@ fn set(toast: ToastMsg, msg: String, is_error: bool) {
toast.0.set(Some((msg.clone(), is_error)));
spawn_local(async move {
gloo_timers::future::sleep(Duration::from_secs(3)).await;
if toast.0.get().as_ref().map(|(m, _)| m.as_str()) == Some(msg.as_str()) {
if toast.0.get_untracked().as_ref().map(|(m, _)| m.as_str()) == Some(msg.as_str()) {
toast.0.set(None);
}
});
▾Mweb/src/i18n.rs
@@ -79,7 +79,13 @@ pub fn set_locale(locale: Locale) {
}
pub fn current() -> Locale {
READ.get().map(|r| r.get()).unwrap_or(Locale::En)
// Tracked inside a reactive scope, so views follow a language switch;
// outside one (event handlers, async tasks) a plain read, which Leptos
// would otherwise warn about.
let tracked = leptos::reactive::graph::Observer::get().is_some();
READ.get()
.map(|r| if tracked { r.get() } else { r.get_untracked() })
.unwrap_or(Locale::En)
}
/// Like [`current`] but without registering a reactive dependency — for
@@ -431,25 +437,35 @@ 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_ACCEPT = "pim_accept" => "Accept",
PIM_ACCESS = "pim_access" => "Access",
PIM_ADD = "pim_add" => "Add",
PIM_ADDRESS = "pim_address" => "Address for apps",
PIM_ADDRESS_BOOK = "pim_address_book" => "Address book",
PIM_ADDRESS_BOOKS = "pim_address_books" => "Address books",
PIM_AGENDA = "pim_agenda" => "Agenda",
PIM_ALL_DAY = "pim_all_day" => "All day",
PIM_ALL_EVENTS = "pim_all_events" => "All events of the series",
PIM_ANNIVERSARY = "pim_anniversary" => "Anniversary",
PIM_APP_PASSWORDS = "pim_app_passwords" => "Create an app password",
PIM_ATTENDEES = "pim_attendees" => "Attendees",
PIM_AUTO = "pim_auto" => "automatic",
PIM_BACK = "pim_back" => "Back",
PIM_BIRTHDAY = "pim_birthday" => "Birthday",
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_CANCELLED = "pim_cancelled" => "Cancelled",
PIM_CATEGORIES = "pim_categories" => "Categories",
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_DECLINE = "pim_decline" => "Decline",
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 “{}”?",
@@ -458,60 +474,96 @@ i18n_keys! {
PIM_DETAIL_BUSY = "pim_detail_busy" => "Busy times only",
PIM_DETAIL_FULL = "pim_detail_full" => "All details",
PIM_DIRECTORY = "pim_directory" => "Directory",
PIM_EMAIL = "pim_email" => "Email",
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_FROM_ORGANIZER = "pim_from_organizer" => "From {}",
PIM_FROM_TIME = "pim_from_time" => "from {}",
PIM_GENERAL = "pim_general" => "General",
PIM_GENERATED_HINT = "pim_generated_hint" => "The server fills this in. It is read-only.",
PIM_GROUP = "pim_group" => "Group",
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 = "pim_import" => "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_FILE = "pim_import_file" => "Import file…",
PIM_IMPORT_HINT_CAL = "pim_import_hint_cal" => "Add the events of an .ics file to this calendar. An event that is already here is updated.",
PIM_IMPORT_HINT_CARDS = "pim_import_hint_cards" => "Add the contacts of a .vcf file to this address book. A contact that is already here is updated.",
PIM_IMPORT_SKIPPED = "pim_import_skipped" => "{} skipped:",
PIM_IMPORT_TITLE = "pim_import_title" => "Import “{}”",
PIM_IMPORTING = "pim_importing" => "Importing…",
PIM_INVITATIONS = "pim_invitations" => "Invitations",
PIM_KIND_RESOURCE = "pim_kind_resource" => "Resource",
PIM_KIND_ROOM = "pim_kind_room" => "Room",
PIM_LINK = "pim_link" => "Link",
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_LOAD_MORE = "pim_load_more" => "Show 30 more days",
PIM_LOCATION = "pim_location" => "Location",
PIM_MEMBERS = "pim_members" => "Members",
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_MONTH = "pim_month" => "Month",
PIM_MORE_N = "pim_more_n" => "+{} more",
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_NEXT_MONTH = "pim_next_month" => "Next month",
PIM_NO_CONTACTS = "pim_no_contacts" => "No contacts yet. Add some with a contacts app or an import.",
PIM_NO_EVENTS_AGENDA = "pim_no_events_agenda" => "No events in this period.",
PIM_NO_EVENTS_DAY = "pim_no_events_day" => "No events on this day.",
PIM_NO_FEEDS = "pim_no_feeds" => "No public feeds.",
PIM_NO_INVITATIONS = "pim_no_invitations" => "No open invitations.",
PIM_NO_LINKS = "pim_no_links" => "No public links.",
PIM_NO_MATCH = "pim_no_match" => "No contact matches.",
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_NO_TITLE = "pim_no_title" => "(no title)",
PIM_NOT_CONTACT = "pim_not_contact" => "This entry is not a contact.",
PIM_NOT_EVENT = "pim_not_event" => "This entry is not an event.",
PIM_NOTE = "pim_note" => "Note",
PIM_OPTIONAL = "pim_optional" => "optional",
PIM_ORGANIZER = "pim_organizer" => "Organizer",
PIM_PASSWORD_HINT = "pim_password_hint" => "Use an app password rather than your account password.",
PIM_PHONE = "pim_phone" => "Phone",
PIM_PICK_CONTACT = "pim_pick_contact" => "Choose a contact to see the details.",
PIM_POSTAL = "pim_postal" => "Address",
PIM_PREV_MONTH = "pim_prev_month" => "Previous month",
PIM_PS_ACCEPTED = "pim_ps_accepted" => "Accepted",
PIM_PS_DECLINED = "pim_ps_declined" => "Declined",
PIM_PS_DELEGATED = "pim_ps_delegated" => "Delegated",
PIM_PS_NEEDS_ACTION = "pim_ps_needs_action" => "No answer yet",
PIM_PS_TENTATIVE = "pim_ps_tentative" => "Maybe",
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_REPEATS = "pim_repeats" => "Repeats",
PIM_REPLIED = "pim_replied" => "Answer sent",
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_RRULE_DAILY = "pim_rrule_daily" => "Every day",
PIM_RRULE_DAILY_N = "pim_rrule_daily_n" => "Every {} days",
PIM_RRULE_MONTHLY = "pim_rrule_monthly" => "Every month",
PIM_RRULE_MONTHLY_N = "pim_rrule_monthly_n" => "Every {} months",
PIM_RRULE_WEEKLY = "pim_rrule_weekly" => "Every week",
PIM_RRULE_WEEKLY_N = "pim_rrule_weekly_n" => "Every {} weeks",
PIM_RRULE_YEARLY = "pim_rrule_yearly" => "Every year",
PIM_RRULE_YEARLY_N = "pim_rrule_yearly_n" => "Every {} years",
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_SEARCH_CONTACTS = "pim_search_contacts" => "Search contacts",
PIM_SERIES = "pim_series" => "Series",
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 {}",
@@ -521,11 +573,20 @@ i18n_keys! {
PIM_SHARING_HINT = "pim_sharing_hint" => "Share with another account on this server.",
PIM_SHOW = "pim_show" => "Show",
PIM_SUBSCRIBE = "pim_subscribe" => "Subscribe",
PIM_TENTATIVE = "pim_tentative" => "Maybe",
PIM_THIS_EVENT = "pim_this_event" => "Only this event",
PIM_TODAY = "pim_today" => "Today",
PIM_TRANSPARENT = "pim_transparent" => "Show as free: its events do not block time in invitations",
PIM_TRUNCATED = "pim_truncated" => "Not every event fits into this view. Hide some calendars to see the rest.",
PIM_UNSHARE = "pim_unshare" => "Stop sharing",
PIM_UNSHARED = "pim_unshared" => "No longer shared",
PIM_UNTIL_TIME = "pim_until_time" => "until {}",
PIM_UPCOMING = "pim_upcoming" => "Upcoming",
PIM_USER = "pim_user" => "User name",
PIM_USER_NAME = "pim_user_name" => "User name",
PIM_WEBSITE = "pim_website" => "Website",
PIM_WHEN = "pim_when" => "When",
PIM_YOUR_ANSWER = "pim_your_answer" => "Your answer",
PROFILE = "profile" => "Profile",
PROFILE_SAVE_ERR = "profile_save_err" => "Could not save profile: {}",
PROFILE_SAVED = "profile_saved" => "Profile saved",
@@ -1130,19 +1191,26 @@ 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_accept", "Zusagen"),
("pim_access", "Zugriff"),
("pim_add", "Hinzufügen"),
("pim_address", "Adresse für Apps"),
("pim_address_book", "Adressbuch"),
("pim_address_books", "Adressbücher"),
("pim_agenda", "Liste"),
("pim_all_day", "Ganztägig"),
("pim_all_events", "Alle Termine der Serie"),
("pim_anniversary", "Jahrestag"),
("pim_app_passwords", "App-Passwort erstellen"),
("pim_attendees", "Teilnehmer"),
("pim_auto", "automatisch"),
("pim_back", "Zurück"),
("pim_birthday", "Geburtstag"),
("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_cancelled", "Abgesagt"),
("pim_categories", "Kategorien"),
("pim_clients", "Apps"),
(
"pim_collection_urls",
@@ -1152,12 +1220,9 @@ const DE: &[(&str, &str)] = &[
("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_decline", "Absagen"),
(
"pim_default_hint",
"Dieser Kalender empfängt Einladungen. Er kann nicht gelöscht werden.",
@@ -1172,21 +1237,21 @@ const DE: &[(&str, &str)] = &[
("pim_detail_busy", "Nur belegte Zeiten"),
("pim_detail_full", "Alle Details"),
("pim_directory", "Verzeichnis"),
("pim_email", "E-Mail"),
("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_from_organizer", "Von {}"),
("pim_from_time", "ab {}"),
("pim_general", "Allgemein"),
(
"pim_generated_hint",
"Der Server füllt dies aus. Es ist schreibgeschützt.",
),
("pim_group", "Gruppe"),
("pim_hide", "Ausblenden"),
(
"pim_hint_apple",
@@ -1204,15 +1269,23 @@ const DE: &[(&str, &str)] = &[
"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", "Importieren"),
("pim_import_done", "{} neu, {} aktualisiert."),
("pim_import_into", "Importieren in"),
("pim_import_menu", "In Kalender oder Kontakte importieren…"),
("pim_import_file", "Datei importieren…"),
(
"pim_import_hint_cal",
"Fügt die Termine einer .ics-Datei zu diesem Kalender hinzu. Ein Termin, der schon da ist, wird aktualisiert.",
),
(
"pim_import_hint_cards",
"Fügt die Kontakte einer .vcf-Datei zu diesem Adressbuch hinzu. Ein Kontakt, der schon da ist, wird aktualisiert.",
),
("pim_import_skipped", "{} übersprungen:"),
("pim_import_title", "„{}“ importieren"),
("pim_importing", "Importiere…"),
("pim_invitations", "Einladungen"),
("pim_kind_resource", "Ressource"),
("pim_kind_room", "Raum"),
("pim_link", "Link"),
("pim_link_busy", "nur belegte Zeiten"),
("pim_link_full", "alle Details"),
("pim_link_revoked", "Link widerrufen"),
@@ -1225,38 +1298,64 @@ const DE: &[(&str, &str)] = &[
"pim_links_hint_cards",
"Jeder mit dem Link kann die Kontakte einmal herunterladen. Apps folgen keinen Änderungen.",
),
("pim_load_more", "30 weitere Tage zeigen"),
("pim_location", "Ort"),
("pim_members", "Mitglieder"),
("pim_mode_ro", "Nur lesen"),
("pim_mode_rw", "Lesen und schreiben"),
(
"pim_mode_schedule",
"Lesen, schreiben und Einladungen senden",
),
("pim_month", "Monat"),
("pim_more_n", "+{} weitere"),
("pim_nav", "Kalender & Kontakte"),
("pim_new_address_book", "Neues Adressbuch"),
("pim_new_calendar", "Neuer Kalender"),
("pim_new_room", "Neuer Raum"),
("pim_next_month", "Nächster Monat"),
(
"pim_no_contacts",
"Noch keine Kontakte. Fügen Sie welche mit einer Kontakte-App oder einem Import hinzu.",
),
("pim_no_events_agenda", "Keine Termine in diesem Zeitraum."),
("pim_no_events_day", "Keine Termine an diesem Tag."),
("pim_no_feeds", "Keine öffentlichen Feeds."),
("pim_no_invitations", "Keine offenen Einladungen."),
("pim_no_links", "Keine öffentlichen Links."),
("pim_no_match", "Kein Kontakt passt."),
("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_no_title", "(ohne Titel)"),
("pim_not_contact", "Dieser Eintrag ist kein Kontakt."),
("pim_not_event", "Dieser Eintrag ist kein Termin."),
("pim_note", "Notiz"),
("pim_optional", "optional"),
("pim_organizer", "Organisator"),
(
"pim_password_hint",
"Nutzen Sie ein App-Passwort statt Ihres Kontopassworts.",
),
("pim_phone", "Telefon"),
(
"pim_pick_contact",
"Wählen Sie einen Kontakt, um die Details zu sehen.",
),
("pim_postal", "Adresse"),
("pim_prev_month", "Vorheriger Monat"),
("pim_ps_accepted", "Zugesagt"),
("pim_ps_declined", "Abgesagt"),
("pim_ps_delegated", "Delegiert"),
("pim_ps_needs_action", "Noch keine Antwort"),
("pim_ps_tentative", "Vielleicht"),
("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_repeats", "Wiederholung"),
("pim_replied", "Antwort gesendet"),
(
"pim_room_delete_confirm",
"„{}“ löschen? Seine Buchungen gehen verloren.",
@@ -1268,9 +1367,17 @@ const DE: &[(&str, &str)] = &[
"pim_rooms_hint",
"Räume und Ressourcen können zu Terminen eingeladen werden. Sie nehmen freie Zeiten an und lehnen belegte selbst ab.",
),
("pim_rrule_daily", "Jeden Tag"),
("pim_rrule_daily_n", "Alle {} Tage"),
("pim_rrule_monthly", "Jeden Monat"),
("pim_rrule_monthly_n", "Alle {} Monate"),
("pim_rrule_weekly", "Jede Woche"),
("pim_rrule_weekly_n", "Alle {} Wochen"),
("pim_rrule_yearly", "Jedes Jahr"),
("pim_rrule_yearly_n", "Alle {} Jahre"),
("pim_save_err", "Speichern fehlgeschlagen: {}"),
("pim_save_here", "Hier speichern"),
("pim_save_to_folder", "In Ordner speichern…"),
("pim_search_contacts", "Kontakte suchen"),
("pim_series", "Serie"),
("pim_server_address", "Serveradresse"),
(
"pim_server_address_hint",
@@ -1286,14 +1393,26 @@ const DE: &[(&str, &str)] = &[
),
("pim_show", "Einblenden"),
("pim_subscribe", "Abonnieren"),
("pim_tentative", "Vielleicht"),
("pim_this_event", "Nur dieser Termin"),
("pim_today", "Heute"),
(
"pim_transparent",
"Als frei anzeigen: Seine Termine blockieren bei Einladungen keine Zeit",
),
(
"pim_truncated",
"Nicht alle Termine passen in diese Ansicht. Blenden Sie Kalender aus, um den Rest zu sehen.",
),
("pim_unshare", "Nicht mehr teilen"),
("pim_unshared", "Nicht mehr geteilt"),
("pim_until_time", "bis {}"),
("pim_upcoming", "Demnächst"),
("pim_user", "Benutzername"),
("pim_user_name", "Benutzername"),
("pim_website", "Webseite"),
("pim_when", "Wann"),
("pim_your_answer", "Ihre Antwort"),
("profile", "Profil"),
(
"profile_save_err",
@@ -1985,19 +2104,26 @@ 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_accept", "Accepter"),
("pim_access", "Accès"),
("pim_add", "Ajouter"),
("pim_address", "Adresse pour les applications"),
("pim_address_book", "Carnet d'adresses"),
("pim_address_books", "Carnets d'adresses"),
("pim_agenda", "Agenda"),
("pim_all_day", "Toute la journée"),
("pim_all_events", "Tous les événements de la série"),
("pim_anniversary", "Anniversaire de mariage"),
("pim_app_passwords", "Créer un mot de passe d'application"),
("pim_attendees", "Participants"),
("pim_auto", "automatique"),
("pim_back", "Retour"),
("pim_birthday", "Anniversaire"),
("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_cancelled", "Annulé"),
("pim_categories", "Catégories"),
("pim_clients", "Applications"),
(
"pim_collection_urls",
@@ -2010,12 +2136,9 @@ const FR: &[(&str, &str)] = &[
"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_decline", "Refuser"),
(
"pim_default_hint",
"Ce calendrier reçoit les invitations. Il ne peut pas être supprimé.",
@@ -2030,21 +2153,21 @@ const FR: &[(&str, &str)] = &[
("pim_detail_busy", "Heures occupées seulement"),
("pim_detail_full", "Tous les détails"),
("pim_directory", "Annuaire"),
("pim_email", "E-mail"),
("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_from_organizer", "De {}"),
("pim_from_time", "à partir de {}"),
("pim_general", "Général"),
(
"pim_generated_hint",
"Le serveur le remplit. Il est en lecture seule.",
),
("pim_group", "Groupe"),
("pim_hide", "Masquer"),
(
"pim_hint_apple",
@@ -2062,18 +2185,23 @@ const FR: &[(&str, &str)] = &[
"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", "Importer"),
("pim_import_done", "{} nouveaux, {} mis à jour."),
("pim_import_into", "Importer dans"),
("pim_import_file", "Importer un fichier…"),
(
"pim_import_menu",
"Importer dans le calendrier ou les contacts…",
"pim_import_hint_cal",
"Ajoute les événements d'un fichier .ics à ce calendrier. Un événement déjà présent est mis à jour.",
),
(
"pim_import_hint_cards",
"Ajoute les contacts d'un fichier .vcf à ce carnet d'adresses. Un contact déjà présent est mis à jour.",
),
("pim_import_skipped", "{} ignorés :"),
("pim_import_title", "Importer « {} »"),
("pim_importing", "Importation…"),
("pim_invitations", "Invitations"),
("pim_kind_resource", "Ressource"),
("pim_kind_room", "Salle"),
("pim_link", "Lien"),
("pim_link_busy", "heures occupées seulement"),
("pim_link_full", "tous les détails"),
("pim_link_revoked", "Lien révoqué"),
@@ -2086,38 +2214,64 @@ const FR: &[(&str, &str)] = &[
"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_load_more", "Afficher 30 jours de plus"),
("pim_location", "Lieu"),
("pim_members", "Membres"),
("pim_mode_ro", "Lecture seule"),
("pim_mode_rw", "Lecture et écriture"),
(
"pim_mode_schedule",
"Lecture, écriture et envoi d'invitations",
),
("pim_month", "Mois"),
("pim_more_n", "+{} autres"),
("pim_nav", "Calendrier et contacts"),
("pim_new_address_book", "Nouveau carnet d'adresses"),
("pim_new_calendar", "Nouveau calendrier"),
("pim_new_room", "Nouvelle salle"),
("pim_next_month", "Mois suivant"),
(
"pim_no_contacts",
"Pas encore de contacts. Ajoutez-en avec une application de contacts ou une importation.",
),
("pim_no_events_agenda", "Aucun événement sur cette période."),
("pim_no_events_day", "Aucun événement ce jour-là."),
("pim_no_feeds", "Aucun flux public."),
("pim_no_invitations", "Aucune invitation en attente."),
("pim_no_links", "Aucun lien public."),
("pim_no_match", "Aucun contact ne correspond."),
("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_no_title", "(sans titre)"),
("pim_not_contact", "Cette entrée n'est pas un contact."),
("pim_not_event", "Cette entrée n'est pas un événement."),
("pim_note", "Note"),
("pim_optional", "facultatif"),
("pim_organizer", "Organisateur"),
(
"pim_password_hint",
"Utilisez un mot de passe d'application plutôt que celui de votre compte.",
),
("pim_phone", "Téléphone"),
(
"pim_pick_contact",
"Choisissez un contact pour voir les détails.",
),
("pim_postal", "Adresse"),
("pim_prev_month", "Mois précédent"),
("pim_ps_accepted", "Accepté"),
("pim_ps_declined", "Refusé"),
("pim_ps_delegated", "Délégué"),
("pim_ps_needs_action", "Pas encore de réponse"),
("pim_ps_tentative", "Peut-être"),
("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_repeats", "Répétition"),
("pim_replied", "Réponse envoyée"),
(
"pim_room_delete_confirm",
"Supprimer « {} » ? Ses réservations sont perdues.",
@@ -2132,9 +2286,17 @@ const FR: &[(&str, &str)] = &[
"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_rrule_daily", "Tous les jours"),
("pim_rrule_daily_n", "Tous les {} jours"),
("pim_rrule_monthly", "Tous les mois"),
("pim_rrule_monthly_n", "Tous les {} mois"),
("pim_rrule_weekly", "Toutes les semaines"),
("pim_rrule_weekly_n", "Toutes les {} semaines"),
("pim_rrule_yearly", "Tous les ans"),
("pim_rrule_yearly_n", "Tous les {} ans"),
("pim_save_err", "Impossible d'enregistrer : {}"),
("pim_save_here", "Enregistrer ici"),
("pim_save_to_folder", "Enregistrer dans un dossier…"),
("pim_search_contacts", "Rechercher des contacts"),
("pim_series", "Série"),
("pim_server_address", "Adresse du serveur"),
(
"pim_server_address_hint",
@@ -2150,14 +2312,26 @@ const FR: &[(&str, &str)] = &[
),
("pim_show", "Afficher"),
("pim_subscribe", "S'abonner"),
("pim_tentative", "Peut-être"),
("pim_this_event", "Seulement cet événement"),
("pim_today", "Aujourd'hui"),
(
"pim_transparent",
"Afficher comme libre : ses événements ne bloquent pas de temps dans les invitations",
),
(
"pim_truncated",
"Tous les événements ne tiennent pas dans cette vue. Masquez des calendriers pour voir le reste.",
),
("pim_unshare", "Ne plus partager"),
("pim_unshared", "N'est plus partagé"),
("pim_until_time", "jusqu'à {}"),
("pim_upcoming", "À venir"),
("pim_user", "Nom d'utilisateur"),
("pim_user_name", "Nom d'utilisateur"),
("pim_website", "Site web"),
("pim_when", "Quand"),
("pim_your_answer", "Votre réponse"),
("profile", "Profil"),
(
"profile_save_err",
▾Mweb/src/router.rs
@@ -5,7 +5,9 @@
//! - `#/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
//! - `#/calendar/month/{yyyy-mm}`, `#/calendar/agenda`,
//! `#/calendar/invitations`, `#/contacts?q=` — 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
@@ -84,7 +86,7 @@ pub struct Location {
/// path in the searched root.
pub open: Option<String>,
/// The search view's own params. Only compared: a new search is a new
/// location.
/// location. On the contacts view, the search text (`q`).
pub search: String,
}
@@ -147,6 +149,9 @@ impl Location {
}
out.push(("size", self.size.to_string()));
}
if self.section == Section::Contacts && !self.search.is_empty() {
out.push(("q", self.search.clone()));
}
if let Some(o) = &self.open {
out.push(("open", o.clone()));
}
@@ -255,6 +260,9 @@ pub fn parse_hash(hash: &str) -> Location {
loc.apply_pair(k, v);
}
}
if loc.section == Section::Contacts {
loc.search = params.get("q").unwrap_or_default();
}
if loc.section == Section::Search {
let _ = params.sort();
for k in QUERY_KEYS {
▾Mweb/src/util.rs
@@ -190,6 +190,26 @@ pub fn format_date(rfc3339: &str) -> String {
rfc3339.to_string()
}
/// Format a JS date in the UI language with `Intl` options such as
/// `[("month", "long")]`. Local time, unless the options name a `timeZone`.
pub fn intl(date: &js_sys::Date, options: &[(&str, &str)]) -> String {
let opts = js_sys::Object::new();
for (k, v) in options {
let _ = js_sys::Reflect::set(&opts, &(*k).into(), &(*v).into());
}
date.to_locale_string(i18n::current_untracked().code(), &opts)
.into()
}
/// An RFC 3339 timestamp as local date and time, e.g. "5 Oct 2026, 10:00".
pub fn local_date_time(rfc3339: &str) -> String {
let d = js_sys::Date::new(&rfc3339.into());
if d.get_time().is_nan() {
return rfc3339.to_string();
}
intl(&d, &[("dateStyle", "medium"), ("timeStyle", "short")])
}
pub fn storage_get(key: &str) -> Option<String> {
let w = web_sys::window()?;
let storage_opt = w.local_storage().ok()?;
▾Mweb/src/views/browser.rs
@@ -1692,29 +1692,6 @@ 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),
▾Aweb/src/views/calendar.rs
@@ -0,0 +1,1598 @@
//! The calendar section's main area: the month grid, the agenda, the open
//! invitations and the details of one event.
//!
//! The server expands recurrences and resolves time zones. This view only
//! places the instances it gets on local days.
use std::collections::{HashMap, HashSet};
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
use crate::api::{
self, PimCollectionInfo, PimEventDetail, PimInstance, PimInvitation, PimObjectDetail, PimReply,
};
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n::{self, Locale, k};
use crate::icons::IconName;
use crate::router::{self, Location, Section, navigate};
use crate::util::{intl, owned_window_listener};
use crate::views::pim::{browser_tz, display_name, safe_color};
// ---------------------------------------------------------------------------
// Days and the month grid (pure, so it is testable off the browser)
// ---------------------------------------------------------------------------
/// Days since 1970-01-01 of a proleptic Gregorian date.
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = i64::from(if m <= 2 { y - 1 } else { y });
let era = y.div_euclid(400);
let yoe = y - era * 400;
let m = i64::from(m);
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = (yoe + era * 400 + i64::from(m <= 2)) as i32;
(y, m, d)
}
/// 0 is Sunday.
pub fn weekday(day: i64) -> u32 {
(day + 4).rem_euclid(7) as u32
}
/// The first day the month grid shows: the week start on or before the 1st.
pub fn grid_start(y: i32, m: u32, week_start: u32) -> i64 {
let first = days_from_civil(y, m, 1);
first - i64::from((weekday(first) + 7 - week_start) % 7)
}
/// The grid always shows six weeks, so its height never jumps.
pub const GRID_DAYS: i64 = 42;
/// Sunday for English, Monday for German and French.
fn week_start(locale: Locale) -> u32 {
match locale {
Locale::En => 0,
Locale::De | Locale::Fr => 1,
}
}
fn add_months(y: i32, m: u32, n: i32) -> (i32, u32) {
let i = y * 12 + m as i32 - 1 + n;
(i.div_euclid(12), i.rem_euclid(12) as u32 + 1)
}
/// Where an instance lies in local time: its first and last day, and the
/// minute of the day it starts on the first and ends on the last. An end at
/// midnight counts as minute 1440 of the day before.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Span {
pub first: i64,
pub last: i64,
pub all_day: bool,
pub start_min: u32,
pub end_min: u32,
}
impl Span {
/// A bar across days, not a line with a time.
fn is_bar(&self) -> bool {
self.all_day || self.first != self.last
}
fn covers(&self, day: i64) -> bool {
self.first <= day && day <= self.last
}
}
/// One piece of an instance in a week row. An instance hidden on some of
/// its days by the "+N more" line becomes several pieces.
#[derive(Clone, Debug, PartialEq)]
pub struct Piece {
/// Index into the spans given to [`layout_week`].
pub item: usize,
pub col: usize,
pub len: usize,
pub line: usize,
/// The instance starts in this piece, rather than continuing into it.
pub starts: bool,
pub ends: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct WeekLayout {
pub pieces: Vec<Piece>,
/// Per day: how many instances the "+N more" line stands for.
pub more: [usize; 7],
}
/// Place a week's instances on `lines` lines. Bars come first, longer ones
/// on top, then timed instances by start. A day with more instances than
/// lines gives its last line to "+N more".
pub fn layout_week(spans: &[Span], week: i64, lines: usize) -> WeekLayout {
let mut items: Vec<(usize, usize, usize)> = spans
.iter()
.enumerate()
.filter(|(_, s)| s.first <= week + 6 && s.last >= week)
.map(|(i, s)| {
let c0 = (s.first.max(week) - week) as usize;
let c1 = (s.last.min(week + 6) - week) as usize;
(i, c0, c1)
})
.collect();
items.sort_by_key(|&(i, c0, c1)| {
let s = &spans[i];
(!s.is_bar(), c0, usize::MAX - (c1 - c0), s.start_min, i)
});
let mut used: Vec<[bool; 7]> = Vec::new();
let mut placed = Vec::with_capacity(items.len());
let mut count = [0usize; 7];
for &(i, c0, c1) in &items {
let line = (0..)
.find(|&l| {
used.get(l)
.is_none_or(|row| !row[c0..=c1].iter().any(|&u| u))
})
.unwrap_or(0);
if used.len() <= line {
used.resize(line + 1, [false; 7]);
}
for c in c0..=c1 {
used[line][c] = true;
count[c] += 1;
}
placed.push((i, c0, c1, line));
}
let limit = |c: usize| {
if count[c] <= lines {
lines
} else {
lines.saturating_sub(1)
}
};
let mut pieces = Vec::new();
for &(i, c0, c1, line) in &placed {
let mut run: Option<usize> = None;
for c in c0..=c1 + 1 {
if c <= c1 && line < limit(c) {
run.get_or_insert(c);
} else if let Some(start) = run.take() {
let s = &spans[i];
pieces.push(Piece {
item: i,
col: start,
len: c - start,
line,
starts: start == c0 && s.first >= week,
ends: c - 1 == c1 && s.last <= week + 6,
});
}
}
}
let more = std::array::from_fn(|c| {
placed
.iter()
.filter(|&&(_, c0, c1, line)| c0 <= c && c <= c1 && line >= limit(c))
.count()
});
WeekLayout { pieces, more }
}
/// What an instance's time looks like on one of its days.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum When {
AllDay,
At(u32),
Range(u32, u32),
From(u32),
Until(u32),
}
pub fn when_on(s: &Span, day: i64) -> When {
if s.all_day {
When::AllDay
} else if s.first == s.last && s.start_min == s.end_min {
When::At(s.start_min)
} else if s.first == s.last {
When::Range(s.start_min, s.end_min)
} else if day == s.first {
When::From(s.start_min)
} else if day == s.last {
When::Until(s.end_min)
} else {
When::AllDay
}
}
/// Split plain text into runs, `true` for an `http(s)://` link. A trailing
/// period or closing bracket belongs to the sentence, not the link.
pub fn split_links(text: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut rest = text;
while let Some(at) = ["https://", "http://"]
.iter()
.filter_map(|p| rest.find(p))
.min()
{
let end = rest[at..]
.find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
.map_or(rest.len(), |e| at + e);
let link = rest[at..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
let link_end = at + link.len();
if at > 0 {
out.push((rest[..at].to_string(), false));
}
out.push((link.to_string(), true));
rest = &rest[link_end..];
}
if !rest.is_empty() {
out.push((rest.to_string(), false));
}
out
}
// ---------------------------------------------------------------------------
// Browser dates
// ---------------------------------------------------------------------------
fn js_day(d: &js_sys::Date) -> i64 {
days_from_civil(d.get_full_year() as i32, d.get_month() + 1, d.get_date())
}
fn day_date(day: i64) -> js_sys::Date {
let (y, m, d) = civil_from_days(day);
js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, d as i32)
}
fn today() -> i64 {
js_day(&js_sys::Date::new_0())
}
/// Local midnight of `day` as RFC 3339 UTC, for the instances endpoint.
fn day_rfc3339(day: i64) -> String {
day_date(day).to_iso_string().into()
}
fn span_of(i: &PimInstance) -> Span {
let s = js_sys::Date::new(&i.start.as_str().into());
let e = js_sys::Date::new(&i.end.as_str().into());
let first = js_day(&s);
let start_min = s.get_hours() * 60 + s.get_minutes();
let (last, end_min) = if e.get_time() > s.get_time() {
let last = js_day(&js_sys::Date::new(&JsValue::from_f64(e.get_time() - 1.0)));
let end_min = if js_day(&e) > last {
1440
} else {
e.get_hours() * 60 + e.get_minutes()
};
(last, end_min)
} else {
(first, start_min)
};
Span {
first,
last: last.max(first),
all_day: i.all_day,
start_min,
end_min,
}
}
fn fmt_min(day: i64, min: u32) -> String {
let (y, m, d) = civil_from_days(day);
let t = js_sys::Date::new_with_year_month_day_hr_min(
y as u32,
m as i32 - 1,
d as i32,
(min / 60) as i32,
(min % 60) as i32,
);
intl(&t, &[("hour", "2-digit"), ("minute", "2-digit")])
}
fn fmt_when(w: When, day: i64) -> String {
match w {
When::AllDay => i18n::t(k::PIM_ALL_DAY).to_string(),
When::At(m) => fmt_min(day, m),
When::Range(a, b) => format!("{} – {}", fmt_min(day, a), fmt_min(day, b)),
When::From(m) => i18n::t_fmt(k::PIM_FROM_TIME, &fmt_min(day, m)),
When::Until(m) => i18n::t_fmt(k::PIM_UNTIL_TIME, &fmt_min(day, m)),
}
}
fn fmt_day_long(day: i64) -> String {
intl(
&day_date(day),
&[
("weekday", "long"),
("day", "numeric"),
("month", "long"),
("year", "numeric"),
],
)
}
fn fmt_day_short(day: i64) -> String {
intl(
&day_date(day),
&[
("weekday", "short"),
("day", "numeric"),
("month", "short"),
("year", "numeric"),
],
)
}
/// The full time of an instance, for its details.
fn fmt_span(s: &Span) -> String {
match (s.all_day, s.first == s.last) {
(true, true) => fmt_day_short(s.first),
(true, false) => format!("{} – {}", fmt_day_short(s.first), fmt_day_short(s.last)),
(false, true) => format!(
"{} · {}",
fmt_day_short(s.first),
fmt_when(when_on(s, s.first), s.first)
),
(false, false) => format!(
"{} {} – {} {}",
fmt_day_short(s.first),
fmt_min(s.first, s.start_min),
fmt_day_short(s.last),
fmt_min(s.last, s.end_min)
),
}
}
// ---------------------------------------------------------------------------
// The view
// ---------------------------------------------------------------------------
/// Lines per day cell in the month grid, the last one shared with
/// "+N more".
const LINES: usize = 4;
#[derive(Clone, Copy, Debug, PartialEq)]
enum CalView {
Month(i32, u32),
Agenda,
Invitations,
}
impl CalView {
fn from_path(path: &[String]) -> Self {
match path.first().map(String::as_str) {
Some("agenda") => CalView::Agenda,
Some("invitations") => CalView::Invitations,
_ => path
.get(1)
.and_then(|ym| {
let (y, m) = ym.split_once('-')?;
let (y, m) = (y.parse().ok()?, m.parse().ok()?);
(1..=12).contains(&m).then_some(CalView::Month(y, m))
})
.unwrap_or_else(|| {
let (y, m, _) = civil_from_days(today());
CalView::Month(y, m)
}),
}
}
fn path(self) -> Vec<String> {
match self {
CalView::Month(y, m) => vec!["month".into(), format!("{y:04}-{m:02}")],
CalView::Agenda => vec!["agenda".into()],
CalView::Invitations => vec!["invitations".into()],
}
}
}
fn go(view: CalView) {
navigate(&Location {
section: Section::Calendar,
path: view.path(),
..Location::root()
});
}
/// The event whose details are open, as the URL names it:
/// `{collection}/{recurrence id or -}/{name}`.
#[derive(Clone, Debug, PartialEq)]
struct Opened {
collection_id: i64,
recurrence_id: Option<String>,
name: String,
}
impl Opened {
fn of(i: &PimInstance) -> Self {
Opened {
collection_id: i.collection_id,
recurrence_id: i.recurrence_id.clone(),
name: i.name.clone(),
}
}
fn param(&self) -> String {
format!(
"{}/{}/{}",
self.collection_id,
self.recurrence_id.as_deref().unwrap_or("-"),
self.name
)
}
fn parse(s: &str) -> Option<Self> {
let mut it = s.splitn(3, '/');
let collection_id = it.next()?.parse().ok()?;
let rid = it.next()?;
let name = it.next()?.to_string();
Some(Opened {
collection_id,
recurrence_id: (rid != "-").then(|| rid.to_string()),
name,
})
}
fn open(&self) {
router::set_open(&self.param());
}
}
/// An invitation as the instance it names, for the day and time helpers.
fn inv_instance(v: &PimInvitation) -> PimInstance {
PimInstance {
collection_id: v.collection_id,
name: v.name.clone(),
uid: v.uid.clone(),
recurrence_id: v.recurrence_id.clone(),
start: v.start.clone(),
end: v.end.clone(),
all_day: v.all_day,
component: "VEVENT".into(),
summary: v.summary.clone(),
location: v.location.clone(),
status: None,
transparent: false,
has_attendees: true,
partstat: None,
organizer: None,
}
}
/// An instance with what the views derive from it.
#[derive(Clone)]
struct Shown {
inst: PimInstance,
span: Span,
color: String,
}
#[component]
pub fn CalendarMain(
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
hidden: RwSignal<HashSet<i64>>,
loc: ReadSignal<Location>,
) -> impl IntoView {
let view = Memo::new(move |_| loc.with(|l| CalView::from_path(&l.path)));
let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(Opened::parse)));
// Bumped after an answer or an import: every list refetches.
let tick = RwSignal::new(0u32);
let month = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
let month_seq = StoredValue::new(0u32);
let month_key = StoredValue::new(None::<(i32, u32)>);
Effect::new(move |_| {
tick.track();
collections.track();
let CalView::Month(y, m) = view.get() else {
return;
};
// Another month's instances would land on the wrong days.
if month_key.get_value() != Some((y, m)) {
month_key.set_value(Some((y, m)));
month.set(None);
}
let start = grid_start(y, m, week_start(i18n::current_untracked()));
let seq = month_seq.get_value() + 1;
month_seq.set_value(seq);
spawn_local(async move {
let r = api::pim_instances(
&day_rfc3339(start),
&day_rfc3339(start + GRID_DAYS),
&browser_tz(),
)
.await;
if month_seq.get_value() == seq {
month.set(Some(r.map_err(|e| e.to_string())));
}
});
});
// The agenda grows by 30 days per "show more".
let agenda_days = RwSignal::new(30i64);
let agenda = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
let agenda_seq = StoredValue::new(0u32);
Effect::new(move |_| {
tick.track();
collections.track();
if view.get() != CalView::Agenda {
return;
}
let days = agenda_days.get();
let from = today();
let seq = agenda_seq.get_value() + 1;
agenda_seq.set_value(seq);
spawn_local(async move {
let r =
api::pim_instances(&day_rfc3339(from), &day_rfc3339(from + days), &browser_tz())
.await;
if agenda_seq.get_value() == seq {
agenda.set(Some(r.map_err(|e| e.to_string())));
}
});
});
let invitations = RwSignal::new(Option::<Result<Vec<PimInvitation>, String>>::None);
Effect::new(move |_| {
tick.track();
spawn_local(async move {
let r = api::pim_invitations(&browser_tz()).await;
let _ = invitations.try_set(Some(r.map_err(|e| e.to_string())));
});
});
let refresh = Callback::new(move |_| tick.update(|t| *t += 1));
let colors = Memo::new(move |_| {
collections.with(|c| {
c.iter()
.flatten()
.map(|c| (c.id, safe_color(c.color.as_deref())))
.collect::<HashMap<_, _>>()
})
});
let shown = move |list: &api::PimInstances| -> Vec<Shown> {
let colors = colors.get();
hidden.with(|h| {
list.instances
.iter()
.filter(|i| !h.contains(&i.collection_id))
.map(|i| Shown {
span: span_of(i),
color: colors
.get(&i.collection_id)
.cloned()
.unwrap_or_else(|| safe_color(None)),
inst: i.clone(),
})
.collect()
})
};
// PageUp / PageDown turn the month, unless typing or a dialog is open.
owned_window_listener(leptos::ev::keydown, move |ev| {
let step = match ev.key().as_str() {
"PageUp" => -1,
"PageDown" => 1,
_ => return,
};
let busy = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| {
d.query_selector("dialog[open], input:focus, textarea:focus")
.ok()
})
.flatten()
.is_some();
if let (CalView::Month(y, m), false) = (view.get_untracked(), busy) {
ev.prevent_default();
let (y, m) = add_months(y, m, step);
go(CalView::Month(y, m));
}
});
let pending = move || {
invitations.with(|i| match i {
Some(Ok(list)) => list.len(),
_ => 0,
})
};
let toolbar = move || {
let v = view.get();
let title = match v {
CalView::Month(y, m) => intl(
&js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, 1),
&[("month", "long"), ("year", "numeric")],
),
CalView::Agenda => i18n::t(k::PIM_UPCOMING).to_string(),
CalView::Invitations => i18n::t(k::PIM_INVITATIONS).to_string(),
};
let nav = match v {
CalView::Month(y, m) => Some(view! {
<div class="cal-nav">
<button
class="icon-btn icon-btn-sm"
title=i18n::t(k::PIM_PREV_MONTH)
aria-label=i18n::t(k::PIM_PREV_MONTH)
on:click=move |_| {
let (y, m) = add_months(y, m, -1);
go(CalView::Month(y, m));
}
>
{icon_svg(IconName::ChevronRight, "ic-btn flip")}
</button>
<button
class="btn btn-sm"
on:click=move |_| {
let (y, m, _) = civil_from_days(today());
go(CalView::Month(y, m));
}
>
{i18n::t(k::PIM_TODAY)}
</button>
<button
class="icon-btn icon-btn-sm"
title=i18n::t(k::PIM_NEXT_MONTH)
aria-label=i18n::t(k::PIM_NEXT_MONTH)
on:click=move |_| {
let (y, m) = add_months(y, m, 1);
go(CalView::Month(y, m));
}
>
{icon_svg(IconName::ChevronRight, "ic-btn")}
</button>
</div>
}),
_ => None,
};
let this_month = match v {
CalView::Month(..) => v,
_ => {
let (y, m, _) = civil_from_days(today());
CalView::Month(y, m)
}
};
view! {
<div class="cal-toolbar">
{nav}
<h2 class="cal-title">{title}</h2>
<div class="cal-views" role="tablist">
<button
role="tab"
class="cal-view-btn"
class:active=matches!(v, CalView::Month(..))
aria-selected=matches!(v, CalView::Month(..)).to_string()
on:click=move |_| go(this_month)
>
{i18n::t(k::PIM_MONTH)}
</button>
<button
role="tab"
class="cal-view-btn"
class:active=v == CalView::Agenda
aria-selected=(v == CalView::Agenda).to_string()
on:click=move |_| go(CalView::Agenda)
>
{i18n::t(k::PIM_AGENDA)}
</button>
<button
role="tab"
class="cal-view-btn"
class:active=v == CalView::Invitations
aria-selected=(v == CalView::Invitations).to_string()
on:click=move |_| go(CalView::Invitations)
>
{i18n::t(k::PIM_INVITATIONS)}
{move || (pending() > 0).then(|| view! {
<span class="cal-badge">{pending()}</span>
})}
</button>
</div>
</div>
}
};
let body = move || match view.get() {
CalView::Month(y, m) => view! {
<MonthView y=y m=m data=month shown=shown/>
}
.into_any(),
CalView::Agenda => view! {
<AgendaView data=agenda days=agenda_days shown=shown/>
}
.into_any(),
CalView::Invitations => view! {
<InvitationsView data=invitations collections=collections on_replied=refresh/>
}
.into_any(),
};
// The times of the open event come from whichever list holds it.
let times = move |o: &Opened| -> Option<Span> {
let find = |list: &Option<Result<api::PimInstances, String>>| {
list.as_ref().and_then(|r| r.as_ref().ok()).and_then(|l| {
l.instances
.iter()
.find(|i| Opened::of(i) == *o)
.map(span_of)
})
};
month
.with_untracked(find)
.or_else(|| agenda.with_untracked(find))
.or_else(|| {
invitations.with_untracked(|i| {
i.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|l| {
l.iter().find(|v| {
v.collection_id == o.collection_id
&& v.name == o.name
&& v.recurrence_id == o.recurrence_id
})
})
.map(|v| span_of(&inv_instance(v)))
})
})
};
view! {
<div class="cal">
{toolbar}
{body}
</div>
{move || opened.get().map(|o| {
let span = times(&o);
view! {
<EventDialog
target=o
span=span
collections=collections
on_replied=refresh
on_close=Callback::new(move |_| router::close_open())
/>
}
})}
}
}
fn truncated_note(list: &api::PimInstances) -> Option<impl IntoView + use<>> {
list.truncated.then(|| {
view! { <p class="cal-truncated">{i18n::t(k::PIM_TRUNCATED)}</p> }
})
}
fn loading_or_error(
data: &Option<Result<api::PimInstances, String>>,
) -> Result<api::PimInstances, AnyView> {
match data {
None => Err(view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any()),
Some(Err(e)) => Err(view! { <div class="card error-card">{e.clone()}</div> }.into_any()),
Some(Ok(list)) => Ok(list.clone()),
}
}
/// One line of a day list: time, color and title.
fn day_row(s: &Shown, day: i64) -> impl IntoView + use<> {
let o = Opened::of(&s.inst);
let when = fmt_when(when_on(&s.span, day), day);
let title = s
.inst
.summary
.clone()
.unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
let cancelled = s.inst.status.as_deref() == Some("CANCELLED");
let location = s.inst.location.clone();
view! {
<button class="cal-row" class:cal-cancelled=cancelled on:click=move |_| o.open()>
<span class="cal-row-time">{when}</span>
<span class="cal-dot" style=format!("--c: {}", s.color)></span>
<span class="cal-row-main">
<span class="cal-row-title">{title}</span>
{location.map(|l| view! { <span class="cal-row-meta">{l}</span> })}
</span>
</button>
}
}
#[component]
fn MonthView(
y: i32,
m: u32,
data: RwSignal<Option<Result<api::PimInstances, String>>>,
shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
) -> impl IntoView {
let start = grid_start(y, m, week_start(i18n::current_untracked()));
let now = today();
let in_month = move |d: i64| {
let (yy, mm, _) = civil_from_days(d);
yy == y && mm == m
};
let selected = RwSignal::new(if in_month(now) {
now
} else {
days_from_civil(y, m, 1)
});
let list_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let pick = move |day: i64| {
selected.set(day);
if let Some(el) = list_ref.get_untracked() {
let opts = web_sys::ScrollIntoViewOptions::new();
opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
el.scroll_into_view_with_scroll_into_view_options(&opts);
}
};
let head = (0..7)
.map(|i| {
view! {
<div class="cal-head-day">{intl(&day_date(start + i), &[("weekday", "short")])}</div>
}
})
.collect::<Vec<_>>();
view! {
<div class="cal-month">
<div class="cal-head">{head}</div>
{move || data.with(|d| match loading_or_error(d) {
Err(v) => v,
Ok(list) => {
let items = shown(&list);
let spans: Vec<Span> = items.iter().map(|s| s.span).collect();
let weeks = (0..6)
.map(|w| {
let week = start + w * 7;
let layout = layout_week(&spans, week, LINES);
let cells = (0..7usize)
.map(|c| {
let day = week + c as i64;
let (_, _, dnum) = civil_from_days(day);
let dots: Vec<String> = items
.iter()
.filter(|s| s.span.covers(day))
.take(3)
.map(|s| s.color.clone())
.collect();
let label = fmt_day_long(day);
view! {
<button
class="cal-cell"
class:cal-out=!in_month(day)
class:cal-today=day == now
class:cal-sel=move || selected.get() == day
style=format!("grid-column: {}", c + 1)
aria-label=label
on:click=move |_| pick(day)
>
<span class="cal-num">{dnum}</span>
<span class="cal-dots">
{dots
.into_iter()
.map(|c| view! { <span class="cal-dot" style=format!("--c: {c}")></span> })
.collect::<Vec<_>>()}
</span>
</button>
}
})
.collect::<Vec<_>>();
let pieces = layout
.pieces
.iter()
.map(|p| {
let s = &items[p.item];
let o = Opened::of(&s.inst);
let bar = s.span.is_bar();
let title = s
.inst
.summary
.clone()
.unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
let time = (!bar).then(|| fmt_min(s.span.first, s.span.start_min));
let cancelled = s.inst.status.as_deref() == Some("CANCELLED");
let tip = match &time {
Some(t) => format!("{t} {title}"),
None => title.clone(),
};
view! {
<button
class="cal-item"
class:cal-bar=bar
class:cal-chip=!bar
class:cal-cont-l=!p.starts
class:cal-cont-r=!p.ends
class:cal-cancelled=cancelled
style=format!(
"grid-column: {} / span {}; grid-row: {}; --c: {}",
p.col + 1,
p.len,
p.line + 2,
s.color
)
title=tip
on:click=move |_| o.open()
>
{(!bar).then(|| view! { <span class="cal-dot"></span> })}
{time.map(|t| view! { <span class="cal-time">{t}</span> })}
<span class="cal-item-title">{title}</span>
</button>
}
})
.collect::<Vec<_>>();
let more = layout
.more
.iter()
.enumerate()
.filter(|&(_, &n)| n > 0)
.map(|(c, &n)| {
let day = week + c as i64;
view! {
<button
class="cal-more"
style=format!("grid-column: {}; grid-row: {}", c + 1, LINES + 1)
on:click=move |_| pick(day)
>
{i18n::t_fmt(k::PIM_MORE_N, &n.to_string())}
</button>
}
})
.collect::<Vec<_>>();
view! {
<div class="cal-week" style=format!("--lines: {LINES}")>
{cells}
{pieces}
{more}
</div>
}
})
.collect::<Vec<_>>();
let day_list = move || {
let day = selected.get();
let rows: Vec<_> = items
.iter()
.filter(|s| s.span.covers(day))
.map(|s| day_row(s, day))
.collect();
view! {
<h3 class="cal-day-title">{fmt_day_long(day)}</h3>
{if rows.is_empty() {
view! { <p class="muted cal-pad">{i18n::t(k::PIM_NO_EVENTS_DAY)}</p> }.into_any()
} else {
view! { <div class="cal-rows">{rows}</div> }.into_any()
}}
}
};
view! {
{truncated_note(&list)}
<div class="cal-weeks">{weeks}</div>
<div class="cal-day" node_ref=list_ref>{day_list}</div>
}
.into_any()
}
})}
</div>
}
}
#[component]
fn AgendaView(
data: RwSignal<Option<Result<api::PimInstances, String>>>,
days: RwSignal<i64>,
shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
) -> impl IntoView {
view! {
<div class="cal-agenda">
{move || data.with(|d| match loading_or_error(d) {
Err(v) => v,
Ok(list) => {
let items = shown(&list);
let from = today();
let groups: Vec<_> = (from..from + days.get_untracked())
.filter_map(|day| {
let rows: Vec<_> = items
.iter()
.filter(|s| s.span.covers(day))
.map(|s| day_row(s, day))
.collect();
(!rows.is_empty()).then(|| view! {
<section class="cal-agenda-day">
<h3 class="cal-day-title" class:cal-day-today=day == from>
{fmt_day_long(day)}
</h3>
<div class="cal-rows">{rows}</div>
</section>
})
})
.collect();
let empty = groups.is_empty().then(|| view! {
<div class="empty-state">
{icon_svg(IconName::Calendar, "empty-glyph")}
<p class="muted">{i18n::t(k::PIM_NO_EVENTS_AGENDA)}</p>
</div>
});
// Each request covers at most 400 days.
let can_grow = days.get_untracked() + 30 <= 390;
view! {
{truncated_note(&list)}
{empty}
{groups}
{can_grow.then(|| view! {
<div class="cal-pad">
<button class="btn btn-sm" on:click=move |_| days.update(|d| *d += 30)>
{i18n::t(k::PIM_LOAD_MORE)}
</button>
</div>
})}
}
.into_any()
}
})}
</div>
}
}
/// Answer an invitation, then refresh the lists.
fn reply(
toast: ToastMsg,
collection_id: i64,
name: String,
recurrence_id: Option<String>,
partstat: &'static str,
done: Callback<()>,
) {
spawn_local(async move {
let body = PimReply {
collection_id,
name,
recurrence_id,
partstat: partstat.to_string(),
tz: Some(browser_tz()),
};
match api::pim_reply(body).await {
Ok(_) => {
show(toast, i18n::t(k::PIM_REPLIED));
done.run(());
}
Err(e) => show_error(toast, e.to_string()),
}
});
}
/// Accept, maybe, decline. `current` marks the answer already given.
fn reply_buttons(
current: Option<String>,
on: impl Fn(&'static str) + Clone + 'static,
) -> impl IntoView {
[
("ACCEPTED", k::PIM_ACCEPT),
("TENTATIVE", k::PIM_TENTATIVE),
("DECLINED", k::PIM_DECLINE),
]
.map(|(ps, label)| {
let on = on.clone();
let active = current.as_deref() == Some(ps);
view! {
<button
class="btn btn-sm"
class:btn-primary=active
aria-pressed=active.to_string()
on:click=move |_| on(ps)
>
{i18n::t(label)}
</button>
}
})
.into_iter()
.collect::<Vec<_>>()
}
#[component]
fn InvitationsView(
data: RwSignal<Option<Result<Vec<PimInvitation>, String>>>,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_replied: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
view! {
<div class="cal-invitations">
{move || match data.get() {
None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(),
Some(Ok(list)) if list.is_empty() => view! {
<div class="empty-state">
{icon_svg(IconName::Calendar, "empty-glyph")}
<p class="muted">{i18n::t(k::PIM_NO_INVITATIONS)}</p>
</div>
}
.into_any(),
Some(Ok(list)) => {
let names: HashMap<i64, String> = collections.with_untracked(|c| {
c.iter().flatten().map(|c| (c.id, display_name(c))).collect()
});
list.into_iter()
.map(|inv| {
let o = Opened {
collection_id: inv.collection_id,
recurrence_id: inv.recurrence_id.clone(),
name: inv.name.clone(),
};
let span = span_of(&inv_instance(&inv));
let mut meta = vec![fmt_span(&span)];
if inv.recurring && inv.recurrence_id.is_none() {
meta.push(i18n::t(k::PIM_SERIES).to_string());
}
if let Some(n) = names.get(&inv.collection_id) {
meta.push(n.clone());
}
let organizer = inv.organizer.as_ref().map(|p| {
p.name.clone().unwrap_or_else(|| {
p.address.trim_start_matches("mailto:").to_string()
})
});
let title = inv
.summary
.clone()
.unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
let (id, name, rid) =
(inv.collection_id, inv.name.clone(), inv.recurrence_id.clone());
let open = o.clone();
view! {
<div class="cal-inv">
<button class="cal-inv-main" on:click=move |_| open.open()>
<span class="cal-inv-title">{title}</span>
<span class="cal-row-meta">{meta.join(" · ")}</span>
{inv.location.clone().map(|l| view! { <span class="cal-row-meta">{l}</span> })}
{organizer.map(|n| view! {
<span class="cal-row-meta">{i18n::t_fmt(k::PIM_FROM_ORGANIZER, &n)}</span>
})}
</button>
<div class="cal-inv-actions">
{reply_buttons(None, move |ps| {
reply(toast, id, name.clone(), rid.clone(), ps, on_replied)
})}
</div>
</div>
}
})
.collect::<Vec<_>>()
.into_any()
}
}}
</div>
}
}
fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) {
match ps {
Some("ACCEPTED") => (k::PIM_PS_ACCEPTED, "ps-accepted"),
Some("DECLINED") => (k::PIM_PS_DECLINED, "ps-declined"),
Some("TENTATIVE") => (k::PIM_PS_TENTATIVE, "ps-tentative"),
Some("DELEGATED") => (k::PIM_PS_DELEGATED, "ps-muted"),
_ => (k::PIM_PS_NEEDS_ACTION, "ps-muted"),
}
}
/// `FREQ=WEEKLY;INTERVAL=2` as words; anything else stays raw.
fn rrule_text(rule: &str) -> String {
let part = |key: &str| {
rule.split(';')
.find_map(|p| p.strip_prefix(key)?.strip_prefix('='))
};
let n = part("INTERVAL")
.and_then(|n| n.parse::<u32>().ok())
.unwrap_or(1);
let keys = match part("FREQ") {
Some("DAILY") => (k::PIM_RRULE_DAILY, k::PIM_RRULE_DAILY_N),
Some("WEEKLY") => (k::PIM_RRULE_WEEKLY, k::PIM_RRULE_WEEKLY_N),
Some("MONTHLY") => (k::PIM_RRULE_MONTHLY, k::PIM_RRULE_MONTHLY_N),
Some("YEARLY") => (k::PIM_RRULE_YEARLY, k::PIM_RRULE_YEARLY_N),
_ => return rule.to_string(),
};
match n {
1 => i18n::t(keys.0).to_string(),
n => i18n::t_fmt(keys.1, &n.to_string()),
}
}
fn person_name(p: &api_types::PimPerson) -> String {
p.name
.clone()
.unwrap_or_else(|| p.address.trim_start_matches("mailto:").to_string())
}
/// Text with its web links clickable, for a `white-space: pre-wrap` block.
pub fn linkified(text: &str) -> impl IntoView + use<> {
split_links(text)
.into_iter()
.map(|(t, link)| match link {
true => {
let href = t.clone();
view! { <a href=href target="_blank" rel="noopener noreferrer">{t}</a> }.into_any()
}
false => t.into_any(),
})
.collect::<Vec<_>>()
}
#[component]
fn EventDialog(
target: Opened,
span: Option<Span>,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_replied: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let detail = RwSignal::new(Option::<Result<PimEventDetail, String>>::None);
let load = {
let t = target.clone();
move || {
let t = t.clone();
spawn_local(async move {
let r = api::pim_object(
t.collection_id,
&t.name,
t.recurrence_id.as_deref(),
&browser_tz(),
)
.await;
let r = match r {
Ok(PimObjectDetail::Event(e)) => Ok(e),
Ok(PimObjectDetail::Contact(_)) => Err(i18n::t(k::PIM_NOT_EVENT).to_string()),
Err(e) => Err(e.to_string()),
};
let _ = detail.try_set(Some(r));
});
}
};
load();
// For one instance of a series: answer it alone, or the whole series.
let only_this = RwSignal::new(true);
let collection = collections.with_untracked(|c| {
c.iter()
.flatten()
.find(|c| c.id == target.collection_id)
.map(|c| (display_name(c), safe_color(c.color.as_deref())))
});
let body = move || match detail.get() {
None => view! { <p class="muted">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <p class="form-error">{e}</p> }.into_any(),
Some(Ok(d)) => {
let title = d
.summary
.clone()
.unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
let mine = d
.attendees
.iter()
.find(|a| a.is_owner)
.and_then(|a| a.partstat.clone());
let series = target.recurrence_id.is_some() && d.rrule.is_some();
let rid = target.recurrence_id.clone();
let (id, name) = (target.collection_id, target.name.clone());
let load = load.clone();
let answer = move |ps: &'static str| {
let rid = if only_this.get_untracked() {
rid.clone()
} else {
None
};
let load = load.clone();
reply(
toast,
id,
name.clone(),
rid,
ps,
Callback::new(move |_| {
load();
on_replied.run(());
}),
);
};
let url = d
.url
.clone()
.filter(|u| u.starts_with("https://") || u.starts_with("http://"));
view! {
<h2 class="modal-title cal-detail-title" class:cal-cancelled=d.status.as_deref() == Some("CANCELLED")>
{title}
</h2>
{(d.status.as_deref() == Some("CANCELLED")).then(|| view! {
<p class="cal-flag">{i18n::t(k::PIM_CANCELLED)}</p>
})}
<dl class="cal-facts">
{span.map(|s| view! {
<dt>{i18n::t(k::PIM_WHEN)}</dt>
<dd>{fmt_span(&s)}</dd>
})}
{d.rrule.clone().map(|r| view! {
<dt>{i18n::t(k::PIM_REPEATS)}</dt>
<dd title=r.clone()>{rrule_text(&r)}</dd>
})}
{d.location.clone().map(|l| view! {
<dt>{i18n::t(k::PIM_LOCATION)}</dt>
<dd class="cal-pre">{linkified(&l)}</dd>
})}
{collection.clone().map(|(n, c)| view! {
<dt>{i18n::t(k::PIM_CALENDAR)}</dt>
<dd><span class="cal-dot" style=format!("--c: {c}")></span>" "{n}</dd>
})}
{d.organizer.as_ref().map(|p| view! {
<dt>{i18n::t(k::PIM_ORGANIZER)}</dt>
<dd>{person_name(p)}</dd>
})}
{url.map(|u| {
let href = u.clone();
view! {
<dt>{i18n::t(k::PIM_LINK)}</dt>
<dd><a href=href target="_blank" rel="noopener noreferrer">{u}</a></dd>
}
})}
{(!d.categories.is_empty()).then(|| view! {
<dt>{i18n::t(k::PIM_CATEGORIES)}</dt>
<dd>{d.categories.join(", ")}</dd>
})}
</dl>
{(!d.attendees.is_empty()).then(|| view! {
<h3 class="pim-section-title">{i18n::t(k::PIM_ATTENDEES)}</h3>
<ul class="cal-attendees">
{d.attendees
.iter()
.map(|a| {
let (label, class) = partstat_label(a.partstat.as_deref());
let optional = a.role.as_deref() == Some("OPT-PARTICIPANT");
view! {
<li>
<span class="cal-att-name" title=a.person.address.clone()>
{person_name(&a.person)}
</span>
{optional.then(|| view! { <span class="muted">{i18n::t(k::PIM_OPTIONAL)}</span> })}
<span class=format!("ps-badge {class}")>{i18n::t(label)}</span>
</li>
}
})
.collect::<Vec<_>>()}
</ul>
})}
{d.description.clone().filter(|t| !t.trim().is_empty()).map(|t| view! {
<div class="cal-desc cal-pre">{linkified(&t)}</div>
})}
{d.can_reply.then(|| view! {
<section class="cal-reply">
<h3 class="pim-section-title">{i18n::t(k::PIM_YOUR_ANSWER)}</h3>
{series.then(|| view! {
<div class="cal-scope" role="radiogroup">
<label class="check-row">
<input
type="radio"
name="cal-scope"
prop:checked=move || only_this.get()
on:change=move |_| only_this.set(true)
/>
{i18n::t(k::PIM_THIS_EVENT)}
</label>
<label class="check-row">
<input
type="radio"
name="cal-scope"
prop:checked=move || !only_this.get()
on:change=move |_| only_this.set(false)
/>
{i18n::t(k::PIM_ALL_EVENTS)}
</label>
</div>
})}
<div class="cal-inv-actions">{reply_buttons(mine, answer)}</div>
</section>
})}
}
.into_any()
}
};
view! {
<Modal class="pim-modal cal-detail" on_close=on_close>
{body}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>{i18n::t(k::CLOSE)}</button>
</div>
</Modal>
}
}
#[cfg(test)]
mod tests {
use super::*;
fn day(y: i32, m: u32, d: u32) -> i64 {
days_from_civil(y, m, d)
}
fn timed(first: i64, start_min: u32) -> Span {
Span {
first,
last: first,
all_day: false,
start_min,
end_min: start_min + 60,
}
}
fn all_day(first: i64, last: i64) -> Span {
Span {
first,
last,
all_day: true,
start_min: 0,
end_min: 1440,
}
}
#[test]
fn civil_days_round_trip() {
assert_eq!(day(1970, 1, 1), 0);
assert_eq!(day(2000, 3, 1), 11017);
for z in [-800_000, -1, 0, 59, 60, 11016, 20_000, 2_000_000] {
let (y, m, d) = civil_from_days(z);
assert_eq!(day(y, m, d), z);
}
assert_eq!(civil_from_days(day(2024, 2, 29)), (2024, 2, 29));
// 2026-10-01 is a Thursday.
assert_eq!(weekday(day(2026, 10, 1)), 4);
}
#[test]
fn grid_starts_on_the_week_start() {
// October 2026 starts on a Thursday.
let sunday = grid_start(2026, 10, 0);
let monday = grid_start(2026, 10, 1);
assert_eq!(civil_from_days(sunday), (2026, 9, 27));
assert_eq!(civil_from_days(monday), (2026, 9, 28));
assert_eq!(weekday(monday), 1);
// A month starting on the week start begins the grid itself, and
// six weeks always cover the whole month.
assert_eq!(grid_start(2026, 6, 1), day(2026, 6, 1));
for (y, m) in [(2026, 2), (2026, 8), (2027, 1)] {
for ws in [0, 1] {
let s = grid_start(y, m, ws);
let (ny, nm) = add_months(y, m, 1);
assert!(s <= day(y, m, 1) && day(ny, nm, 1) <= s + GRID_DAYS);
}
}
assert_eq!(add_months(2026, 12, 1), (2027, 1));
assert_eq!(add_months(2026, 1, -1), (2025, 12));
}
#[test]
fn bars_split_across_weeks() {
// Saturday to Tuesday, in weeks starting on Monday.
let week1 = grid_start(2026, 10, 1);
let span = all_day(day(2026, 10, 3), day(2026, 10, 6));
let a = layout_week(&[span], week1, 4);
assert_eq!(
a.pieces,
[Piece {
item: 0,
col: 5,
len: 2,
line: 0,
starts: true,
ends: false
}]
);
let b = layout_week(&[span], week1 + 7, 4);
assert_eq!(
b.pieces,
[Piece {
item: 0,
col: 0,
len: 2,
line: 0,
starts: false,
ends: true
}]
);
assert_eq!(a.more, [0; 7]);
}
#[test]
fn busy_days_share_the_last_line_with_more() {
let week = grid_start(2026, 10, 1);
let mon = week;
// Five timed events on Monday; a bar Monday to Wednesday.
let mut spans: Vec<Span> = (0..5).map(|i| timed(mon, 600 + i * 30)).collect();
spans.push(all_day(mon, mon + 2));
let l = layout_week(&spans, week, 3);
// Monday has six instances on three lines: two shown, four behind
// "+4 more". The bar comes first, so it stays visible.
assert_eq!(l.more, [4, 0, 0, 0, 0, 0, 0]);
let bar = l.pieces.iter().find(|p| p.item == 5).unwrap();
assert_eq!((bar.col, bar.len, bar.line), (0, 3, 0));
assert_eq!(l.pieces.iter().filter(|p| p.col == 0).count(), 2);
// Timed events keep their order.
let first_chip = l.pieces.iter().find(|p| p.item != 5).unwrap();
assert_eq!(first_chip.item, 0);
}
#[test]
fn a_bar_hidden_on_a_busy_day_splits() {
let week = grid_start(2026, 10, 1);
// Two bars over Mon–Wed; three timed events on Tuesday only.
let mut spans = vec![all_day(week, week + 2), all_day(week, week + 2)];
spans.extend((0..3).map(|i| timed(week + 1, 600 + i * 60)));
let l = layout_week(&spans, week, 2);
// Tuesday overflows, so its last line (the second bar) shows "+4".
assert_eq!(l.more, [0, 4, 0, 0, 0, 0, 0]);
let second: Vec<_> = l.pieces.iter().filter(|p| p.item == 1).collect();
assert_eq!(second.len(), 2);
assert_eq!(
(second[0].col, second[0].len, second[0].ends),
(0, 1, false)
);
assert_eq!(
(second[1].col, second[1].len, second[1].starts),
(2, 1, false)
);
}
#[test]
fn times_on_each_day() {
let s = Span {
first: 10,
last: 12,
all_day: false,
start_min: 22 * 60,
end_min: 9 * 60,
};
assert_eq!(when_on(&s, 10), When::From(1320));
assert_eq!(when_on(&s, 11), When::AllDay);
assert_eq!(when_on(&s, 12), When::Until(540));
assert_eq!(when_on(&timed(5, 600), 5), When::Range(600, 660));
assert_eq!(when_on(&all_day(5, 5), 5), When::AllDay);
}
#[test]
fn links_are_found_in_text() {
assert_eq!(
split_links("See https://example.com/a?b=1. Or http://x.org)"),
[
("See ".to_string(), false),
("https://example.com/a?b=1".to_string(), true),
(". Or ".to_string(), false),
("http://x.org".to_string(), true),
(")".to_string(), false),
]
);
assert_eq!(
split_links("no links, javascript:alert(1)"),
[("no links, javascript:alert(1)".to_string(), false)]
);
}
#[test]
fn opened_round_trips() {
let o = Opened {
collection_id: 7,
recurrence_id: Some("2026-10-05T08:00:00Z".into()),
name: "a/b.ics".into(),
};
assert_eq!(Opened::parse(&o.param()), Some(o));
let series = Opened {
collection_id: -1,
recurrence_id: None,
name: "x.ics".into(),
};
assert_eq!(Opened::parse(&series.param()), Some(series));
}
}
▾Aweb/src/views/contacts.rs
@@ -0,0 +1,356 @@
//! The contacts section's main area: a searchable list of the visible
//! address books and the details of one contact.
use std::collections::HashSet;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, PimCollectionInfo, PimContact, PimContactDetail, PimObjectDetail};
use crate::components::icon::icon_svg;
use crate::i18n::{self, k};
use crate::icons::IconName;
use crate::router::{self, Location};
use crate::util::intl;
use crate::views::calendar::linkified;
use crate::views::pim::{browser_tz, display_name, safe_color};
/// The open contact, as the URL names it: `{collection}/{name}`.
fn parse_open(s: &str) -> Option<(i64, String)> {
let (id, name) = s.split_once('/')?;
Some((id.parse().ok()?, name.to_string()))
}
/// Up to two initials for a contact without photo.
fn initials(name: &str) -> String {
name.split_whitespace()
.filter_map(|w| w.chars().next())
.take(2)
.flat_map(char::to_uppercase)
.collect()
}
/// A vCard date: `1980-03-15`, `19800315`, or without year `--03-15` and
/// `--0315`. `None` when it is none of these.
pub fn vcard_date(s: &str) -> Option<(Option<i32>, u32, u32)> {
let s = s.split('T').next()?.replace('-', "");
let (year, md) = match s.len() {
8 => (Some(s[..4].parse().ok()?), &s[4..]),
4 => (None, &s[..]),
_ => return None,
};
let (m, d) = (md[..2].parse().ok()?, md[2..].parse().ok()?);
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some((year, m, d))
}
fn fmt_vcard_date(s: &str) -> String {
let Some((year, m, d)) = vcard_date(s) else {
return s.to_string();
};
// A leap year, so February 29 exists when the year is unknown.
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(
js_sys::Date::utc(f64::from(year.unwrap_or(2000)), f64::from(m - 1))
+ f64::from(d - 1) * 86_400_000.0,
));
match year {
Some(_) => intl(
&date,
&[
("timeZone", "UTC"),
("day", "numeric"),
("month", "long"),
("year", "numeric"),
],
),
None => intl(
&date,
&[("timeZone", "UTC"), ("day", "numeric"), ("month", "long")],
),
}
}
/// Initials, covered by the photo when there is one. A photo that fails to
/// load hides itself and leaves the initials.
fn avatar(name: &str, photo: Option<String>, class: &'static str) -> impl IntoView + use<> {
view! {
<span class=format!("ct-avatar ct-initials {class}") aria-hidden="true">
{initials(name)}
{photo.map(|src| view! {
<img
class="ct-photo"
src=src
alt=""
loading="lazy"
on:error=|ev| {
let _ = event_target::<web_sys::HtmlElement>(&ev)
.style()
.set_property("display", "none");
}
/>
})}
</span>
}
}
#[component]
pub fn ContactsMain(
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
hidden: RwSignal<HashSet<i64>>,
loc: ReadSignal<Location>,
) -> impl IntoView {
let query = Memo::new(move |_| loc.with(|l| l.search.clone()));
let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(parse_open)));
let input = RwSignal::new(query.get_untracked());
let timer = StoredValue::new_local(None::<gloo_timers::callback::Timeout>);
let typed = move |v: String| {
input.set(v.clone());
// One request per pause in typing, and no history entry per key.
timer.set_value(Some(gloo_timers::callback::Timeout::new(300, move || {
let mut l = loc.get_untracked();
l.search = v.trim().to_string();
router::replace(&l);
})));
};
let contacts = RwSignal::new(Option::<Result<Vec<PimContact>, String>>::None);
let seq = StoredValue::new(0u32);
Effect::new(move |_| {
collections.track();
let q = query.get();
let n = seq.get_value() + 1;
seq.set_value(n);
spawn_local(async move {
let r = api::pim_contacts(&q).await;
if seq.get_value() == n {
let _ = contacts.try_set(Some(r.map_err(|e| e.to_string())));
}
});
});
let detail = RwSignal::new(Option::<Result<PimContactDetail, String>>::None);
Effect::new(move |_| {
let Some((id, name)) = opened.get() else {
detail.set(None);
return;
};
detail.set(None);
spawn_local(async move {
let r = match api::pim_object(id, &name, None, &browser_tz()).await {
Ok(PimObjectDetail::Contact(c)) => Ok(c),
Ok(PimObjectDetail::Event(_)) => Err(i18n::t(k::PIM_NOT_CONTACT).to_string()),
Err(e) => Err(e.to_string()),
};
if opened.get_untracked() == Some((id, name)) {
let _ = detail.try_set(Some(r));
}
});
});
let list = move || {
let open = opened.get();
match contacts.get() {
None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(),
Some(Ok(all)) => {
let rows: Vec<_> = hidden.with(|h| {
all.into_iter()
.filter(|c| !h.contains(&c.collection_id))
.collect()
});
if rows.is_empty() {
let msg = match query.get().is_empty() {
true => k::PIM_NO_CONTACTS,
false => k::PIM_NO_MATCH,
};
return view! {
<div class="empty-state">
{icon_svg(IconName::Contacts, "empty-glyph")}
<p class="muted">{i18n::t(msg)}</p>
</div>
}
.into_any();
}
rows.into_iter()
.map(|c| {
let key = (c.collection_id, c.name.clone());
let selected = open.as_ref() == Some(&key);
let param = format!("{}/{}", c.collection_id, c.name);
let meta: Vec<String> = [c.org.clone(), c.email.clone(), c.phone.clone()]
.into_iter()
.flatten()
.collect();
view! {
<button
class="ct-row"
class:ct-sel=selected
aria-current=selected.then_some("true")
on:click=move |_| router::set_open(&param)
>
{avatar(
&c.full_name,
c.has_photo.then(|| api::pim_photo_url(c.collection_id, &c.name)),
"",
)}
<span class="ct-row-main">
<span class="ct-name">
<span class="ct-name-text">{c.full_name.clone()}</span>
{c.is_group.then(|| view! { <span class="badge">{i18n::t(k::PIM_GROUP)}</span> })}
</span>
{(!meta.is_empty()).then(|| view! { <span class="ct-meta">{meta.join(" · ")}</span> })}
</span>
</button>
}
})
.collect::<Vec<_>>()
.into_any()
}
}
};
let book = move |id: i64| {
collections.with(|c| {
c.iter()
.flatten()
.find(|c| c.id == id)
.map(|c| (display_name(c), safe_color(c.color.as_deref())))
})
};
let pane = move || match (opened.get(), detail.get()) {
(None, _) => view! {
<div class="empty-state ct-placeholder">
{icon_svg(IconName::Contacts, "empty-glyph")}
<p class="muted">{i18n::t(k::PIM_PICK_CONTACT)}</p>
</div>
}
.into_any(),
(Some(_), None) => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
(Some(_), Some(Err(e))) => view! { <div class="card error-card">{e}</div> }.into_any(),
(Some(_), Some(Ok(c))) => {
let sub: Vec<String> = [c.title.clone(), c.org.clone()]
.into_iter()
.flatten()
.collect();
let head_photo = avatar(&c.full_name, c.photo_url.clone(), "ct-avatar-lg");
let labeled = |key: &'static str,
items: Vec<api_types::PimLabeled>,
href: fn(&str) -> Option<String>| {
(!items.is_empty()).then(|| view! {
<dt>{i18n::t(key)}</dt>
<dd>
{items
.into_iter()
.map(|l| {
let label = l.label.clone();
let value = match href(&l.value) {
Some(h) => {
// mailto: and tel: open an app, not a tab.
let tab = h.starts_with("http").then_some("_blank");
view! {
<a href=h target=tab rel="noopener noreferrer">{l.value.clone()}</a>
}
.into_any()
}
None => view! { <span class="cal-pre">{l.value.clone()}</span> }.into_any(),
};
view! {
<div class="ct-value">
{value}
{label.map(|t| view! { <span class="muted ct-label">{t}</span> })}
</div>
}
})
.collect::<Vec<_>>()}
</dd>
})
};
let web = |v: &str| {
(v.starts_with("https://") || v.starts_with("http://")).then(|| v.to_string())
};
let collection = book(c.collection_id);
view! {
<button class="btn btn-sm ct-back" on:click=move |_| router::close_open()>
{icon_svg(IconName::ChevronRight, "ic-btn flip")}
{i18n::t(k::PIM_BACK)}
</button>
<div class="ct-head">
{head_photo}
<div class="ct-head-text">
<h2 class="ct-title">{c.full_name.clone()}</h2>
{(!sub.is_empty()).then(|| view! { <p class="muted">{sub.join(" · ")}</p> })}
</div>
</div>
<dl class="cal-facts">
{labeled(k::PIM_EMAIL, c.emails.clone(), |v| Some(format!("mailto:{v}")))}
{labeled(k::PIM_PHONE, c.phones.clone(), |v| {
Some(format!("tel:{}", v.chars().filter(|c| !c.is_whitespace()).collect::<String>()))
})}
{labeled(k::PIM_POSTAL, c.addresses.clone(), |_| None)}
{labeled(k::PIM_WEBSITE, c.urls.clone(), web)}
{c.birthday.clone().map(|b| view! {
<dt>{i18n::t(k::PIM_BIRTHDAY)}</dt>
<dd>{fmt_vcard_date(&b)}</dd>
})}
{c.anniversary.clone().map(|b| view! {
<dt>{i18n::t(k::PIM_ANNIVERSARY)}</dt>
<dd>{fmt_vcard_date(&b)}</dd>
})}
{(!c.members.is_empty()).then(|| view! {
<dt>{i18n::t(k::PIM_MEMBERS)}</dt>
<dd>{c.members.join(", ")}</dd>
})}
{collection.map(|(n, color)| view! {
<dt>{i18n::t(k::PIM_ADDRESS_BOOK)}</dt>
<dd><span class="cal-dot" style=format!("--c: {color}")></span>" "{n}</dd>
})}
</dl>
{c.note.clone().filter(|n| !n.trim().is_empty()).map(|n| view! {
<h3 class="pim-section-title">{i18n::t(k::PIM_NOTE)}</h3>
<div class="cal-desc cal-pre">{linkified(&n)}
</div>
})}
}
.into_any()
}
};
view! {
<div class="ct" class:ct-has-open=move || opened.get().is_some()>
<div class="ct-list">
<div class="ct-search">
{icon_svg(IconName::Search, "ct-search-icon")}
<input
type="search"
placeholder=i18n::tr(k::PIM_SEARCH_CONTACTS)
aria-label=i18n::tr(k::PIM_SEARCH_CONTACTS)
prop:value=move || input.get()
on:input=move |ev| typed(event_target_value(&ev))
/>
</div>
<div class="ct-rows">{list}</div>
</div>
<div class="ct-detail">{pane}</div>
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vcard_dates() {
assert_eq!(vcard_date("1980-03-15"), Some((Some(1980), 3, 15)));
assert_eq!(vcard_date("19800315"), Some((Some(1980), 3, 15)));
assert_eq!(vcard_date("--03-15"), Some((None, 3, 15)));
assert_eq!(vcard_date("--0229"), Some((None, 2, 29)));
assert_eq!(
vcard_date("1980-03-15T00:00:00Z"),
Some((Some(1980), 3, 15))
);
assert_eq!(vcard_date("sometime"), None);
assert_eq!(vcard_date("1980-13-01"), None);
assert_eq!(initials("anna maria berg"), "AM");
assert_eq!(initials("ümit"), "Ü");
assert_eq!(parse_open("3/a/b.vcf"), Some((3, "a/b.vcf".to_string())));
}
}
▾Mweb/src/views/dialogs.rs
@@ -109,12 +109,6 @@ 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,
@@ -253,19 +247,6 @@ 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
@@ -1,5 +1,7 @@
pub mod admin;
pub mod browser;
pub mod calendar;
pub mod contacts;
pub mod dialogs;
pub mod file_view;
pub mod login;
▾Mweb/src/views/pim.rs
@@ -9,8 +9,8 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{
self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview,
PimShareInfo, PimShareMode,
self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimShareInfo,
PimShareMode,
};
use crate::components::icon::{Icon, icon_svg};
use crate::components::modal::Modal;
@@ -18,8 +18,10 @@ 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::util::{copy_to_clipboard, local_date_time, storage_get, storage_set};
use crate::views::calendar::CalendarMain;
use crate::views::contacts::ContactsMain;
use crate::views::dialogs::ConfirmDialog;
use crate::views::shares::expiry_to_rfc3339;
/// Colors offered for a collection; a custom one comes from the color input.
@@ -93,7 +95,7 @@ pub fn browser_tz() -> String {
pub fn PimView(
kind: PimCollectionKind,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
loc: ReadSignal<Location>,
) -> impl IntoView {
let (collections, set_collections) = signal(Option::<Vec<PimCollectionInfo>>::None);
let (load_err, set_load_err) = signal(Option::<String>::None);
@@ -108,10 +110,12 @@ pub fn PimView(
});
});
reload.run(());
let (glyph, title, hint) = match kind {
PimCollectionKind::Calendar => (IconName::Calendar, k::PIM_CALENDAR, k::PIM_CAL_HINT),
let main = match kind {
PimCollectionKind::Calendar => {
view! { <CalendarMain collections=collections hidden=hidden loc=loc/> }.into_any()
}
PimCollectionKind::Addressbook => {
(IconName::Contacts, k::PIM_CONTACTS, k::PIM_CONTACTS_HINT)
view! { <ContactsMain collections=collections hidden=hidden loc=loc/> }.into_any()
}
};
view! {
@@ -123,15 +127,8 @@ pub fn PimView(
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>
<section class="pim-main">{main}</section>
</div>
}
}
@@ -144,7 +141,6 @@ fn CollectionList(
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);
@@ -250,8 +246,6 @@ fn CollectionList(
{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
/>
@@ -426,8 +420,6 @@ fn url_row(url: String, toast: ToastMsg) -> impl IntoView {
#[component]
fn CollectionDialog(
info: PimCollectionInfo,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
on_close: Callback<()>,
on_changed: Callback<()>,
) -> impl IntoView {
@@ -457,7 +449,8 @@ fn CollectionDialog(
</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/>
{writable(&info).then(|| view! { <ImportSection info=info.clone() on_changed=on_changed/> })}
<ExportSection info=info.clone()/>
{(!info.generated).then(|| view! {
<DeleteSection info=info.clone() on_close=on_close on_changed=on_changed/>
})}
@@ -739,7 +732,7 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
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)));
meta.push(i18n::t_fmt(k::META_EXPIRES, &local_date_time(e)));
}
if l.has_password {
meta.push(i18n::t(k::SHARE_PROTECTED).to_string());
@@ -873,69 +866,108 @@ fn export_file(info: &PimCollectionInfo) -> String {
}
#[component]
fn ExportSection(
info: PimCollectionInfo,
me: ReadSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
fn ExportSection(info: PimCollectionInfo) -> impl IntoView {
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>
<button
type="button"
class="btn btn-sm"
on:click=move |_| api::trigger_download(&api::pim_export_url(id), &file)
>
{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)}
</div>
</section>
}
}
/// Upload an `.ics` or `.vcf` file into the collection.
#[component]
fn ImportSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let input: NodeRef<leptos::html::Input> = NodeRef::new();
let (busy, set_busy) = signal(false);
let (result, set_result) = signal(Option::<PimImportResult>::None);
let picked = move |_| {
let Some(file) = input
.get_untracked()
.and_then(|el| el.files())
.and_then(|list| list.get(0))
else {
return;
};
set_busy.set(true);
set_result.set(None);
spawn_local(async move {
match api::pim_import(id, file).await {
Ok(r) => {
set_result.set(Some(r));
on_changed.run(());
}
Err(e) => show_error(toast, e.to_string()),
}
set_busy.set(false);
// The same file again must fire `change` again.
if let Some(el) = input.get_untracked() {
el.set_value("");
}
});
};
view! {
<section class="pim-section">
<h3 class="pim-section-title">{i18n::tr(k::PIM_IMPORT)}</h3>
<p class="muted pim-hint">{i18n::tr(match info.kind {
PimCollectionKind::Calendar => k::PIM_IMPORT_HINT_CAL,
PimCollectionKind::Addressbook => k::PIM_IMPORT_HINT_CARDS,
})}</p>
<input
node_ref=input
type="file"
class="pim-file-input"
accept=".ics,.vcf,text/calendar,text/vcard"
on:change=picked
/>
<div class="pim-section-actions pim-left">
<button
type="button"
class="btn btn-sm"
disabled=move || busy.get()
on:click=move |_| {
if let Some(el) = input.get_untracked() {
el.click();
}
}
>
{icon_svg(IconName::Upload, "ic-btn")}
{move || if busy.get() { i18n::t(k::PIM_IMPORTING) } else { i18n::t(k::PIM_IMPORT_FILE) }}
</button>
</div>
{move || result.get().map(|r| {
let more = r.skipped_total.saturating_sub(r.skipped.len());
view! {
<p class="pim-hint">
{i18n::t_fmt2(k::PIM_IMPORT_DONE, &r.created.to_string(), &r.updated.to_string())}
</p>
{(r.skipped_total > 0).then(|| view! {
<p class="pim-hint">{i18n::t_fmt(k::PIM_IMPORT_SKIPPED, &r.skipped_total.to_string())}</p>
<ul class="pim-sample">
{r.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>
})}
}
})}
</section>
}
}
@@ -1094,204 +1126,3 @@ fn ConnectDialog(
</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/shell.rs
@@ -365,11 +365,11 @@ pub fn ShellView(
.into_any(),
Section::Uploads => view! { <UploadsView/> }.into_any(),
Section::Calendar => view! {
<PimView kind=PimCollectionKind::Calendar me=me set_dialog=set_dialog/>
<PimView kind=PimCollectionKind::Calendar me=me loc=loc/>
}
.into_any(),
Section::Contacts => view! {
<PimView kind=PimCollectionKind::Addressbook me=me set_dialog=set_dialog/>
<PimView kind=PimCollectionKind::Addressbook me=me loc=loc/>
}
.into_any(),
}