Calendar and contacts UI: feedback round 1
- Import a file as a new collection (POST /api/pim/import): name from the request, the file's NAME/X-WR-CALNAME or the file name; hex color from the file or the UI; removed again when nothing imports - Share dialog suggests accounts through a datalist, fed by GET .../shares/candidates (active, not the owner, not yet borrowers) - Collection list: a click on a name shows only that one, "Show all" and a second click undo it; the gear opens the dialog - Generated collections are gray; a new collection gets the first unused palette color; contact rows show their address book's color - Hiding a collection closes the event or contact open from it - Invitations name the series rule and say the answer covers the series; admin feed links are grouped by owner; the agenda shows 7 days - Fixes: toggle hover edge, unstyled room type select, phone contact list wider than the screen, feed and "show as free" wording in EN/DE/FR - Docs: room answer limit, import as new, UI changes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MREADME.md
@@ -278,12 +278,18 @@ never fetched.
### Web UI
The sidebar has a Calendar and a Contacts page. Each lists your own
collections, the generated ones and the ones lent to you. The color square
hides or shows a collection. A click on a name opens its dialog: name,
color, description, "show as free", sharing, public links, 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.
collections, the generated ones and the ones lent to you. The generated
ones are gray, and a new collection gets a palette color no other one
uses. The color square hides or shows a collection, so several can show
merged. A click on a name shows only that collection; a second click, or
"Show all", shows them all again. The gear opens a collection's dialog:
name, color, description, "show as free", sharing, public links, import,
download and delete. When sharing, the name field suggests the accounts
the collection is not lent to yet. The upload button next to **+**
creates a collection from an `.ics` or `.vcf` file. "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, grouped by
owner.
The Calendar page has three views:
@@ -294,9 +300,12 @@ The Calendar page has three views:
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.
- **Agenda**: today and the next six days, day by day. The month view is
for browsing further.
- **Invitations**: the invitations you have not answered, with Accept,
Maybe and Decline. The count shows on the tab.
Maybe and Decline. The count shows on the tab. An invitation to a series
shows its repetition, and the answer applies to the whole series. An
invitation to one date of a series shows that date.
A click on an event opens its details: time, repetition, place, organizer,
attendees with their answers and the description. For an invitation it
@@ -306,8 +315,9 @@ 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.
at a time, with its photo, addresses and notes. A colored square on each
row names its address book. On a phone the list and the contact take
turns. Hiding a collection closes the event or contact open from it.
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),
@@ -407,8 +417,17 @@ 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`).
`POST /api/pim/import?kind=calendar|addressbook` imports a file as a new
collection. Its name is the `name` parameter, else the name the file gives
itself (`NAME` or `X-WR-CALNAME`), else the `file` parameter without its
extension. Its color is the file's `COLOR` or `X-APPLE-CALENDAR-COLOR`,
if that is a hex color, else the `color` parameter. When no entry can be
imported, the collection is removed again and the answer has no
`collection`.
In the web UI, each collection's dialog has "Import file…" and
"Download".
"Download", and the upload button in the collection list creates a new
collection from a file.
### Invitations
@@ -447,6 +466,12 @@ invitations itself. An instance that overlaps an existing booking is
declined. Everything else is accepted. For a repeating meeting, only the
instances that collide are declined.
A room answers once, when it receives an invitation. A collision beyond
the checked window stays accepted: a series without end is checked two
years ahead, so two such series that first collide in year three both
stay accepted. Only an update from an organizer makes the room check
again, from that day on.
### Deleting an account, room or resource
The server removes the deleted one from everyone else's events in the same
Mapi-types/src/lib.rs
@@ -81,11 +81,19 @@ pub const ADMIN_PIM_LINKS: &str = "/api/admin/pim-links";
/// an own one; `DELETE` on `.../{user_id}` below it ends a loan.
pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
pub const SHARES_SUFFIX: &str = "/shares";
/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}`: the accounts
/// an own collection can still be lent to, as [`PimShareCandidate`].
pub const CANDIDATES_SUFFIX: &str = "/candidates";
/// `{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.
pub const IMPORT_SUFFIX: &str = "/import";
/// `POST`: an `.ics` or `.vcf` body as a new calendar or address book, as
/// [`PimImportNew`]. Params `kind` (`calendar`, `addressbook`), optional
/// `name`, `file` (the file name, a fallback name) and `color` (used when the
/// file names none).
pub const PIM_IMPORT_NEW: &str = "/api/pim/import";
/// `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: the collection as one file.
pub const EXPORT_SUFFIX: &str = "/export";
/// `GET`: the system address book as one file. It has no collection id.
@@ -790,6 +798,9 @@ pub struct PimInvitation {
pub end: String,
pub all_day: bool,
pub recurring: bool,
/// The series' RRULE when the invitation is for the whole series.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rrule: Option<String>,
}
/// `POST {PIM_INVITATIONS}`: the calendar owner's answer. The server writes
@@ -875,6 +886,23 @@ pub struct PimImportResult {
pub skipped: Vec<PimSkipped>,
}
/// The answer to `POST {PIM_IMPORT_NEW}`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimImportNew {
/// `None` when nothing could be imported: then no collection was made.
pub collection: Option<PimCollectionInfo>,
#[serde(flatten)]
pub result: PimImportResult,
}
/// An account a collection can be lent to.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimShareCandidate {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PimSkipped {
pub uid: Option<String>,
Mpimdav/README.md
@@ -357,6 +357,9 @@ copy carries the status of its last reply on the ORGANIZER.
years ahead. Later instances are accepted unchecked. A series without
end has infinite instances, so it needs a limit. Exchange's booking
window works the same way.
- The answer is given once, on receipt, and stored. Nothing checks it
again later. A collision beyond the checked window stays accepted until
the organizer sends an update, which the room answers from that day on.
## Feeds, export and import
@@ -406,6 +409,10 @@ the output unchanged. Only line endings become CRLF.
VERSION right after BEGIN.
- The objects are not validated here. The server runs the same checks as
for a PUT and skips what fails.
- `calendar_meta` reads the name and color a file gives itself, for an
import as a new calendar: NAME before X-WR-CALNAME, COLOR before
X-APPLE-CALENDAR-COLOR. RFC 7986's COLOR is a CSS color name, which the
server ignores; only hex colors count.
## Contacts
Mpimdav/src/bundle.rs
@@ -96,6 +96,26 @@ pub fn cards(objects: &[&str]) -> String {
out
}
/// The display name and color a calendar file names for itself: RFC 7986's
/// NAME and COLOR, or the X-WR-CALNAME and X-APPLE-CALENDAR-COLOR most
/// exports carry instead. The color is as written; the caller checks it.
pub fn calendar_meta(text: &str) -> (Option<String>, Option<String>) {
let Some(cal) = top_blocks(text, "VCALENDAR").into_iter().next() else {
return (None, None);
};
let first = |props: &[&str]| {
props
.iter()
.find_map(|p| cal.prop(p))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
};
(
first(&["NAME", "X-WR-CALNAME"]),
first(&["COLOR", "X-APPLE-CALENDAR-COLOR"]),
)
}
/// Splits an iCalendar file into calendar object resources: one per UID,
/// overrides with their master, each with the VTIMEZONEs it names. Keeps
/// VERSION, PRODID and CALSCALE of the file and drops the other calendar
Mpimdav/tests/bundle.rs
@@ -170,3 +170,23 @@ fn quoted_tzid_keeps_its_zone() {
assert_eq!(objects.len(), 1);
assert!(objects[0].contains("TZID:Work\\; late: shift"));
}
#[test]
fn calendar_names_itself() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Ferien\\, Bayern\r\nX-APPLE-CALENDAR-COLOR:#FF2968FF\r\nBEGIN:VEVENT\r\nUID:a\r\nSUMMARY:x\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
assert_eq!(
bundle::calendar_meta(ics),
(Some("Ferien, Bayern".into()), Some("#FF2968FF".into()))
);
// RFC 7986 wins over the X- names, and a component's own NAME does not count.
let ics = ics.replace(
"VERSION:2.0\r\n",
"VERSION:2.0\r\nNAME:Holidays\r\nCOLOR:teal\r\n",
);
assert_eq!(
bundle::calendar_meta(&ics),
(Some("Holidays".into()), Some("teal".into()))
);
let bare = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nNAME:not me\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
assert_eq!(bundle::calendar_meta(bare), (None, None));
}
Mserver/src/api/mod.rs
@@ -3,10 +3,11 @@ use std::sync::Arc;
use api_types::{
ADMIN_PIM_LINKS, ADMIN_ROOMS, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS,
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_SYSTEM_EXPORT, SEARCH, SHARE,
SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, CANDIDATES_SUFFIX, DAV, DAV_SHARE,
EXPORT_SUFFIX, FEED, FILES, FINISH_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX,
PHOTO_SUFFIX, PIM, PIM_COLLECTIONS, PIM_CONTACTS, PIM_IMPORT_NEW, PIM_INSTANCES,
PIM_INVITATIONS, PIM_SYSTEM_EXPORT, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX,
WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -120,6 +121,7 @@ pub fn router(state: Arc<AppState>) -> Router {
let admin_room_id = format!("{ADMIN_ROOMS}/{{id}}");
let pim_shares = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}");
let pim_share = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}/{{user_id}}");
let pim_candidates = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}");
let pim_links = format!("{PIM_COLLECTIONS}/{{id}}{LINKS_SUFFIX}");
let pim_link = format!("{PIM_COLLECTIONS}/{{id}}{LINKS_SUFFIX}/{{link_id}}");
let pim_import = format!("{PIM_COLLECTIONS}/{{id}}{IMPORT_SUFFIX}");
@@ -205,9 +207,11 @@ pub fn router(state: Arc<AppState>) -> Router {
)
.route(&pim_shares, get(pim_api::shares).post(pim_api::share))
.route(&pim_share, delete(pim_api::unshare))
.route(&pim_candidates, get(pim_api::share_candidates))
.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_IMPORT_NEW, post(pim_api::import_new))
.route(&pim_export, get(pim_api::export))
.route(PIM_SYSTEM_EXPORT, get(pim_api::export_system))
.route(&pim_photo, get(pim_api::photo))
Mserver/src/api/pim_api.rs
@@ -3,12 +3,14 @@
//! - `PUT`, `DELETE {PIM_COLLECTIONS}/{id}` — change or delete one, or end its loan
//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection
//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}` — who it can be lent to
//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection
//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root
//! - `GET`, `POST {PIM_SYSTEM_EXPORT}` — the same for the system address book
//! - `POST {PIM_IMPORT_NEW}` — import a file as a new collection
//! - `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download
//! - `GET {PIM_SYSTEM_EXPORT}` — the same for the system address book
//!
//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo
//!
@@ -19,12 +21,12 @@ use std::sync::Arc;
use api_types::{
CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo,
PimCollectionKind, PimImportResult, PimLinkInfo, PimShareInfo, PimShareMode, PimSkipped,
UpdatePimCollection,
PimCollectionKind, PimImportNew, PimImportResult, PimLinkInfo, PimShareCandidate, PimShareInfo,
PimShareMode, PimSkipped, UpdatePimCollection,
};
use axum::Json;
use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
@@ -41,7 +43,7 @@ use crate::api::pim::{
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::auth;
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace};
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace, User};
use crate::error::{ApiError, AppState};
/// The largest file an import reads.
@@ -133,6 +135,10 @@ pub async fn list(
Ok(Json(out))
}
/// The generated collections are gray, so they never look like one of the
/// user's own. Keep it out of the web UI's palette.
const GENERATED_COLOR: &str = "#94a3b8";
/// A generated collection without its members, which listing it needs
/// not build.
fn generated_info(id: i64) -> PimCollection {
@@ -141,6 +147,7 @@ fn generated_info(id: i64) -> PimCollection {
id,
slug: BIRTHDAYS_SLUG.to_string(),
displayname: Some("Birthdays".to_string()),
color: Some(GENERATED_COLOR.to_string()),
components: "VEVENT".to_string(),
transparent: true,
..Default::default()
@@ -149,6 +156,7 @@ fn generated_info(id: i64) -> PimCollection {
id,
slug: DIRECTORY_SLUG.to_string(),
displayname: Some("Directory".to_string()),
color: Some(GENERATED_COLOR.to_string()),
..Default::default()
},
}
@@ -197,22 +205,42 @@ pub async fn create(
auth: SessionUser,
Json(body): Json<CreatePimCollection>,
) -> Result<Json<PimCollectionInfo>, ApiError> {
let me = &auth.user;
let pid = state.db.principal_of(me.id).await?;
let kind = db_kind(body.kind);
let name = body.name.trim();
if name.is_empty() {
return Err(bad_request("a name is required"));
}
let color = body.color.filter(|c| !c.trim().is_empty());
if color.as_deref().is_some_and(|c| !valid_color(c)) {
return Err(bad_request("invalid color"));
}
let info = create_collection(
&state,
&auth.user,
db_kind(body.kind),
&body.name,
color,
body.description.filter(|d| !d.trim().is_empty()),
&body.components,
)
.await?;
Ok(Json(info))
}
/// A new own collection, with a slug made from its name.
async fn create_collection(
state: &AppState,
me: &User,
kind: PimKind,
name: &str,
color: Option<String>,
description: Option<String>,
components: &[String],
) -> Result<PimCollectionInfo, ApiError> {
let pid = state.db.principal_of(me.id).await?;
let name = name.trim();
if name.is_empty() {
return Err(bad_request("a name is required"));
}
let components = match kind {
PimKind::Calendar if body.components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::Calendar if components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::Calendar => {
let comps: Vec<String> = body
.components
let comps: Vec<String> = components
.iter()
.map(|c| c.trim().to_ascii_uppercase())
.collect();
@@ -232,7 +260,7 @@ pub async fn create(
};
let mut col = PimCollection {
displayname: Some(name.to_string()),
description: body.description.filter(|d| !d.trim().is_empty()),
description,
color,
components,
..Default::default()
@@ -251,7 +279,7 @@ pub async fn create(
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let url = collection_href(&me.name, kind, &slug, None);
return Ok(Json(info(&c, kind, url, &me.name, None)));
return Ok(info(&c, kind, url, &me.name, None));
}
}
Err(ApiError::new(StatusCode::CONFLICT, "no free name"))
@@ -358,6 +386,26 @@ pub async fn shares(
Ok(Json(out))
}
/// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}
///
/// Every signed-in user already sees all accounts in principal search and
/// the system address book, so listing them here reveals nothing new.
pub async fn share_candidates(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<PimShareCandidate>>, ApiError> {
let id = own(&state, &auth, id).await?;
let out = state
.db
.pim_share_candidates(id, auth.user.id)
.await?
.into_iter()
.map(|(name, display_name)| PimShareCandidate { name, display_name })
.collect();
Ok(Json(out))
}
/// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
pub async fn share(
State(state): State<Arc<AppState>>,
@@ -726,18 +774,83 @@ pub async fn import(
if !writable {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let text = read_import(body).await?;
let parts = split_import(kind, &text)?;
Ok(Json(import_parts(&state, owner, kind, &col, parts).await?))
}
#[derive(serde::Deserialize)]
pub struct ImportNewQuery {
kind: PimCollectionKind,
name: Option<String>,
file: Option<String>,
color: Option<String>,
}
/// POST {PIM_IMPORT_NEW}: a file as a new collection. Its name comes from the
/// request, else from the file's own name for itself, else from the file
/// name. When nothing can be imported, the collection is removed again.
pub async fn import_new(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Query(q): Query<ImportNewQuery>,
body: Body,
) -> Result<Json<PimImportNew>, ApiError> {
let kind = db_kind(q.kind);
let text = read_import(body).await?;
let parts = split_import(kind, &text)?;
let (own_name, own_color) = match kind {
PimKind::Calendar => bundle::calendar_meta(&text),
PimKind::AddressBook => (None, None),
};
let nonempty = |s: Option<String>| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let stem = q
.file
.map(|f| f.rsplit_once('.').map_or(f.clone(), |(s, _)| s.to_string()));
let name = nonempty(q.name)
.or(nonempty(own_name))
.or(nonempty(stem))
.ok_or_else(|| bad_request("a name is required"))?;
// COLOR may be a CSS color name, which the web UI cannot show.
let color = own_color
.filter(|c| valid_color(c))
.or(q.color.filter(|c| valid_color(c)));
let info = create_collection(&state, &auth.user, kind, &name, color, None, &[]).await?;
let pid = state.db.principal_of(auth.user.id).await?;
let (_, _, col) = state
.db
.pim_collection_by_id(info.id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let result = import_parts(&state, pid, kind, &col, parts).await;
let keep = matches!(&result, Ok(r) if r.created + r.updated > 0);
if !keep {
// Empty and never lent or synced: nothing to cancel, nobody to tell.
let _ = delete_own(&state, pid, kind, &col).await?;
}
Ok(Json(PimImportNew {
collection: keep.then_some(info),
result: result?,
}))
}
async fn read_import(body: Body) -> Result<String, ApiError> {
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());
Ok(String::from_utf8(data)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect()))
}
/// One text per resource of an import file.
fn split_import(kind: PimKind, text: &str) -> Result<Vec<String>, ApiError> {
// From the content, so importing the same file twice updates.
let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string();
let parts = match kind {
PimKind::Calendar => bundle::split_calendar(&text, &mut new_uid),
PimKind::AddressBook => bundle::split_cards(&text, &mut new_uid),
PimKind::Calendar => bundle::split_calendar(text, &mut new_uid),
PimKind::AddressBook => bundle::split_cards(text, &mut new_uid),
};
if parts.is_empty() {
return Err(ApiError::new(
@@ -745,9 +858,18 @@ pub async fn import(
"the file holds no calendar or address objects",
));
}
Ok(parts)
}
async fn import_parts(
state: &AppState,
owner: i64,
kind: PimKind,
col: &PimCollection,
parts: Vec<String>,
) -> Result<PimImportResult, ApiError> {
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(&state).await?;
let dir = Directory::load(state).await?;
let owner = dir
.get(owner)
.cloned()
@@ -806,8 +928,7 @@ pub async fn import(
let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind));
let schedule_tag = match kind {
PimKind::Calendar => {
match pim_schedule::import_tag(&state, &dir, &owner, (col.id, &name), &data).await?
{
match pim_schedule::import_tag(state, &dir, &owner, (col.id, &name), &data).await? {
Ok(tag) => tag,
Err(condition) => {
skip(Some(uid), &condition.name);
@@ -838,5 +959,5 @@ pub async fn import(
state.db.pim_apply(&ops).await?;
result.created = created;
result.updated = updated;
Ok(Json(result))
Ok(result)
}
Mserver/src/api/pim_views.rs
@@ -446,6 +446,7 @@ pub async fn invitations(
end: rfc3339(next.end),
all_day: info.all_day,
recurring: info.rrule.is_some() && !is_override,
rrule: info.rrule.clone().filter(|_| !is_override),
});
}
}
Mserver/src/db.rs
@@ -1955,6 +1955,24 @@ impl Db {
.collect()
}
/// Active accounts a collection could still be lent to: not `me`, not
/// already a borrower. `(name, display name)`, by name.
pub async fn pim_share_candidates(
&self,
collection_id: i64,
me: i64,
) -> DbResult<Vec<(String, Option<String>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.name, p.display_name FROM users u JOIN principals p ON p.user_id = u.id
WHERE u.active = 1 AND u.id != ?2
AND u.id NOT IN (SELECT user_id FROM pim_shares WHERE collection_id = ?1)
ORDER BY u.name COLLATE NOCASE",
)?;
stmt.query_map(params![collection_id, me], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
}
/// Lends a collection, or changes the mode of an existing loan.
pub async fn pim_set_share(
&self,
Mserver/tests/api_pim_io.rs
@@ -380,3 +380,126 @@ async fn export_round_trip() {
assert!(r.header("content-disposition").unwrap().contains(".vcf"));
assert!(r.text().contains("FN:alice"));
}
#[tokio::test]
async fn import_as_a_new_collection() {
let io = Io::new().await;
let post = |query: &str, body: String| {
let (client, query) = (&io.alice, query.to_string());
async move {
let r = client
.raw(
Method::POST,
&format!("/api/pim/import?{query}"),
&[("content-type", "text/calendar")],
body.into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json()
}
};
let before = collections(&io.alice).await;
let vevent = |uid: &str, summary: &str| {
format!(
"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\n"
)
};
// The file names itself and its color.
let ics = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Ferien\r\nX-APPLE-CALENDAR-COLOR:#FF2968FF\r\n{}END:VCALENDAR\r\n",
vevent("f1", "Sommer")
);
let r = post("kind=calendar&file=holidays.ics&color=%2322c55e", ics).await;
assert_eq!(r["created"], 1, "{r}");
assert_eq!(r["collection"]["name"], "Ferien", "{r}");
assert_eq!(r["collection"]["color"], "#FF2968FF", "{r}");
// A typed name wins; without a color in the file the requested one counts.
let plain = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nCOLOR:teal\r\n{}END:VCALENDAR\r\n",
vevent("f2", "Winter")
);
let r = post("kind=calendar&name=Mine&file=x.ics&color=%2322c55e", plain).await;
assert_eq!(r["collection"]["name"], "Mine", "{r}");
assert_eq!(r["collection"]["color"], "#22c55e", "{r}");
// An address book takes the file name.
let vcf = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Ann\r\nN:;Ann;;;\r\nEND:VCARD\r\n";
let r = post("kind=addressbook&file=Team%20contacts.vcf", vcf.to_string()).await;
assert_eq!(r["collection"]["name"], "Team contacts", "{r}");
assert_eq!(r["collection"]["kind"], "addressbook", "{r}");
assert_eq!(collections(&io.alice).await, before + 3);
// Nothing importable: reported, and no empty collection is left behind.
let broken = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:no start\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let r = post("kind=calendar&file=bad.ics", broken.to_string()).await;
assert_eq!(r["collection"], Value::Null, "{r}");
assert_eq!(r["skipped_total"], 1, "{r}");
assert_eq!(collections(&io.alice).await, before + 3);
}
async fn collections(c: &Client) -> usize {
c.get("/api/pim/collections")
.await
.json()
.as_array()
.unwrap()
.len()
}
#[tokio::test]
async fn share_candidates_leave_out_owner_borrowers_and_disabled() {
let io = Io::new().await;
let admin = login(&io.env, "admin", "admin1234").await;
create_user(&admin, "carol", PW, &[]).await;
create_user(&admin, "dave", PW, &[]).await;
let dave = user_id(&admin, "dave").await;
let r = admin
.put_json(
&format!("/api/admin/users/{dave}"),
&json!({"active": false}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let r = admin
.post_json(
"/api/admin/rooms",
&json!({"name": "atrium", "kind": "room"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let id = io.id(CAL).await;
let r = io
.alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": "bob", "mode": "ro"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let names = |v: Value| -> Vec<String> {
v.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap().to_string())
.collect()
};
let r = io
.alice
.get(&format!("/api/pim/collections/{id}/shares/candidates"))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// Not alice herself, not bob (a borrower), not dave (disabled), no room.
assert_eq!(names(r.json()), ["admin", "carol"]);
// Only the owner may ask.
let bob = login(&io.env, "bob", PW).await;
let r = bob
.get(&format!("/api/pim/collections/{id}/shares/candidates"))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
Mweb/app.css
@@ -2993,6 +2993,15 @@ body:has(.page-busy) {
white-space: nowrap;
}
.pim-row-gear {
flex: none;
margin-right: 4px;
}
.pim-show-all {
align-self: flex-start;
}
.pim-row-note {
flex: none;
max-width: 45%;
@@ -3260,10 +3269,12 @@ body:has(.page-busy) {
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. */
/* Selects in the calendar and contacts views, their dialogs, and any form
field: the same height and colors as the inputs beside them, in both
themes. */
.pim-modal select,
.pim-view select {
.pim-view select,
select.field-input {
appearance: none;
padding: 10px 34px 10px 12px;
border: 1px solid var(--border);
@@ -3278,7 +3289,8 @@ body:has(.page-busy) {
}
.pim-modal select:focus,
.pim-view select:focus {
.pim-view select:focus,
select.field-input:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
@@ -3349,6 +3361,15 @@ body:has(.page-busy) {
border-color: var(--accent-border);
color: #fff;
position: relative;
z-index: 1;
}
/* The buttons overlap by one pixel; the hovered one must draw on top, or its
neighbor covers its right edge. */
.cal-view-btn:hover,
.cal-view-btn:focus-visible {
position: relative;
z-index: 2;
}
.cal-badge {
@@ -3767,6 +3788,8 @@ body:has(.page-busy) {
flex-direction: column;
border-right: 1px solid var(--border);
min-height: 0;
/* Rows do not wrap; on a phone the list must not grow to their width. */
min-width: 0;
}
.ct-search {
@@ -3822,6 +3845,15 @@ body:has(.page-busy) {
box-shadow: inset 3px 0 0 var(--accent);
}
/* The contact's address book, colored like its entry in the list beside. */
.ct-book {
flex: none;
width: 10px;
height: 10px;
margin-left: auto;
background: var(--swatch);
}
.ct-avatar {
flex: none;
width: 36px;
Mweb/src/api.rs
@@ -24,15 +24,16 @@ use api_types::{
SortKey, UnlockShare, UpdateUser,
};
use api_types::{
ADMIN_PIM_LINKS, ADMIN_ROOMS, CreatePimCollection, CreatePimLink, CreatePimShare, CreateRoom,
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,
ADMIN_PIM_LINKS, ADMIN_ROOMS, CANDIDATES_SUFFIX, CreatePimCollection, CreatePimLink,
CreatePimShare, CreateRoom, EXPORT_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, P_FROM,
P_RECURRENCE_ID, P_TO, P_TZ, PHOTO_SUFFIX, PIM_COLLECTIONS, PIM_CONTACTS, PIM_IMPORT_NEW,
PIM_INSTANCES, PIM_INVITATIONS, SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
};
pub use api_types::{
AdminPimLink, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail,
PimEventDetail, PimImportResult, PimInstance, PimInstances, PimInvitation, PimLinkInfo,
PimObjectDetail, PimReply, PimShareInfo, PimShareMode, RoomInfo, RoomKind,
PimEventDetail, PimImportNew, PimImportResult, PimInstance, PimInstances, PimInvitation,
PimLinkInfo, PimObjectDetail, PimReply, PimShareCandidate, PimShareInfo, PimShareMode,
RoomInfo, RoomKind,
};
pub use api_types::{
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
@@ -1220,6 +1221,17 @@ pub fn pim_shares(
)
}
/// The accounts an own collection can still be lent to.
pub fn pim_share_candidates(
id: i64,
) -> impl std::future::Future<Output = Result<Vec<PimShareCandidate>, ApiError>> {
request(
"GET",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}"),
None::<()>,
)
}
/// Lends a collection, or changes the mode of a loan.
pub fn pim_share(
id: i64,
@@ -1288,6 +1300,32 @@ pub async fn pim_import(id: i64, file: web_sys::File) -> Result<PimImportResult,
read_json(&resp).await
}
/// Uploads a file as a new collection. An empty `name` lets the server take
/// the file's own name for itself, or `file_name`.
pub async fn pim_import_new(
kind: PimCollectionKind,
name: &str,
color: &str,
file: web_sys::File,
) -> Result<PimImportNew, ApiError> {
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let kind = match kind {
PimCollectionKind::Calendar => "calendar",
PimCollectionKind::Addressbook => "addressbook",
};
let url = format!(
"{PIM_IMPORT_NEW}?kind={kind}&name={}&file={}&color={}",
enc(name),
enc(&file.name()),
enc(color)
);
opts.set_body(&file.into());
let resp = fetch_checked(&url, &opts, "import failed").await?;
read_json(&resp).await
}
/// Downloads a collection as one `.ics` or `.vcf` file.
pub fn pim_export_url(id: i64) -> String {
format!("{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}")
Mweb/src/i18n.rs
@@ -484,6 +484,7 @@ i18n_keys! {
PIM_EXPORT = "pim_export" => "Export",
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 = "pim_file" => "File",
PIM_FROM_ORGANIZER = "pim_from_organizer" => "From {}",
PIM_FROM_TIME = "pim_from_time" => "from {}",
PIM_GENERAL = "pim_general" => "General",
@@ -499,8 +500,13 @@ i18n_keys! {
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_NEW_HINT_CAL = "pim_import_new_hint_cal" => "Creates a calendar with the events of an .ics file.",
PIM_IMPORT_NEW_HINT_CARDS = "pim_import_new_hint_cards" => "Creates an address book with the contacts of a .vcf file.",
PIM_IMPORT_NOTHING = "pim_import_nothing" => "Nothing could be imported, so nothing was created.",
PIM_IMPORT_SKIPPED = "pim_import_skipped" => "{} skipped:",
PIM_IMPORTED_AS = "pim_imported_as" => "Imported as “{}”.",
PIM_IMPORTING = "pim_importing" => "Importing…",
PIM_INV_SERIES_HINT = "pim_inv_series_hint" => "Your answer applies to the whole series.",
PIM_INVITATIONS = "pim_invitations" => "Invitations",
PIM_KIND_RESOURCE = "pim_kind_resource" => "Resource",
PIM_KIND_ROOM = "pim_kind_room" => "Room",
@@ -509,23 +515,28 @@ i18n_keys! {
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 = "pim_links_hint" => "Anyone with the link can subscribe. Some calendars, such as 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_MANAGE = "pim_manage" => "Settings",
PIM_MANAGE_OF = "pim_manage_of" => "Settings for {}",
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_NAME_FROM_FILE = "pim_name_from_file" => "From the file if empty",
PIM_NAV = "pim_nav" => "Calendar & contacts",
PIM_NEW_ADDRESS_BOOK = "pim_new_address_book" => "New address book",
PIM_NEW_BOOK_FROM_FILE = "pim_new_book_from_file" => "New address book from file",
PIM_NEW_CALENDAR = "pim_new_calendar" => "New calendar",
PIM_NEW_CALENDAR_FROM_FILE = "pim_new_calendar_from_file" => "New calendar from file",
PIM_NEW_FROM_FILE = "pim_new_from_file" => "New from file…",
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_AGENDA = "pim_no_events_agenda" => "No events in the next 7 days.",
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.",
@@ -569,7 +580,6 @@ i18n_keys! {
PIM_RRULE_YEARLY_N = "pim_rrule_yearly_n" => "Every {} years",
PIM_SAVE_ERR = "pim_save_err" => "Could not save: {}",
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 {}",
@@ -578,6 +588,8 @@ i18n_keys! {
PIM_SHARING = "pim_sharing" => "Sharing",
PIM_SHARING_HINT = "pim_sharing_hint" => "Share with another account on this server.",
PIM_SHOW = "pim_show" => "Show",
PIM_SHOW_ALL = "pim_show_all" => "Show all",
PIM_SHOW_ONLY = "pim_show_only" => "Show only this one",
PIM_SKIP_COMPONENT = "pim_skip_component" => "this type is not accepted here",
PIM_SKIP_INVALID = "pim_skip_invalid" => "invalid or incomplete data",
PIM_SKIP_SIZE = "pim_skip_size" => "too large",
@@ -586,12 +598,12 @@ i18n_keys! {
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_TRANSPARENT = "pim_transparent" => "Show as free: events do not block time slots 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_UPCOMING = "pim_upcoming" => "Next 7 days",
PIM_USER = "pim_user" => "User name",
PIM_USER_NAME = "pim_user_name" => "User name",
PIM_WEBSITE = "pim_website" => "Website",
@@ -1265,6 +1277,7 @@ const DE: &[(&str, &str)] = &[
"pim_feeds_hint",
"Jeder öffentliche Kalender- und Adressbuch-Link auf diesem Server.",
),
("pim_file", "Datei"),
("pim_from_organizer", "Von {}"),
("pim_from_time", "ab {}"),
("pim_general", "Allgemein"),
@@ -1301,8 +1314,25 @@ const DE: &[(&str, &str)] = &[
"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_new_hint_cal",
"Legt einen Kalender mit den Terminen einer .ics-Datei an.",
),
(
"pim_import_new_hint_cards",
"Legt ein Adressbuch mit den Kontakten einer .vcf-Datei an.",
),
(
"pim_import_nothing",
"Nichts ließ sich importieren, daher wurde nichts angelegt.",
),
("pim_import_skipped", "{} übersprungen:"),
("pim_imported_as", "Importiert als „{}“."),
("pim_importing", "Importiere…"),
(
"pim_inv_series_hint",
"Ihre Antwort gilt für die ganze Serie.",
),
("pim_invitations", "Einladungen"),
("pim_kind_resource", "Ressource"),
("pim_kind_room", "Raum"),
@@ -1313,14 +1343,15 @@ const DE: &[(&str, &str)] = &[
("pim_links", "Öffentliche Links"),
(
"pim_links_hint",
"Jeder mit dem Link kann ihn abonnieren. Google Kalender und Outlook.com brauchen einen Link ohne Passwort.",
"Jeder mit dem Link kann ihn abonnieren. Manche Kalender, z. B. Google Kalender und Outlook.com, brauchen einen Link ohne Passwort.",
),
(
"pim_links_hint_cards",
"Jeder mit dem Link kann die Kontakte einmal herunterladen. Apps folgen keinen Änderungen.",
),
("pim_load_more", "30 weitere Tage zeigen"),
("pim_location", "Ort"),
("pim_manage", "Einstellungen"),
("pim_manage_of", "Einstellungen für {}"),
("pim_members", "Mitglieder"),
("pim_mode_ro", "Nur lesen"),
("pim_mode_rw", "Lesen und schreiben"),
@@ -1330,16 +1361,23 @@ const DE: &[(&str, &str)] = &[
),
("pim_month", "Monat"),
("pim_more_n", "+{} weitere"),
("pim_name_from_file", "Aus der Datei, wenn leer"),
("pim_nav", "Kalender & Kontakte"),
("pim_new_address_book", "Neues Adressbuch"),
("pim_new_book_from_file", "Neues Adressbuch aus Datei"),
("pim_new_calendar", "Neuer Kalender"),
("pim_new_calendar_from_file", "Neuer Kalender aus Datei"),
("pim_new_from_file", "Neu aus Datei…"),
("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_agenda",
"Keine Termine in den nächsten 7 Tagen.",
),
("pim_no_events_day", "Keine Termine an diesem Tag."),
("pim_no_feeds", "Keine öffentlichen Feeds."),
("pim_no_invitations", "Keine offenen Einladungen."),
@@ -1398,7 +1436,6 @@ const DE: &[(&str, &str)] = &[
("pim_rrule_yearly_n", "Alle {} Jahre"),
("pim_save_err", "Speichern fehlgeschlagen: {}"),
("pim_search_contacts", "Kontakte suchen"),
("pim_series", "Serie"),
("pim_server_address", "Serveradresse"),
(
"pim_server_address_hint",
@@ -1413,6 +1450,8 @@ const DE: &[(&str, &str)] = &[
"Mit einem anderen Konto auf diesem Server teilen.",
),
("pim_show", "Einblenden"),
("pim_show_all", "Alle anzeigen"),
("pim_show_only", "Nur diesen Eintrag anzeigen"),
(
"pim_skip_component",
"dieser Typ wird hier nicht angenommen",
@@ -1429,7 +1468,7 @@ const DE: &[(&str, &str)] = &[
("pim_today", "Heute"),
(
"pim_transparent",
"Als frei anzeigen: Seine Termine blockieren bei Einladungen keine Zeit",
"Als frei anzeigen: Termine blockieren bei Einladungen keine Zeitfenster",
),
(
"pim_truncated",
@@ -1438,7 +1477,7 @@ const DE: &[(&str, &str)] = &[
("pim_unshare", "Nicht mehr teilen"),
("pim_unshared", "Nicht mehr geteilt"),
("pim_until_time", "bis {}"),
("pim_upcoming", "Demnächst"),
("pim_upcoming", "Nächste 7 Tage"),
("pim_user", "Benutzername"),
("pim_user_name", "Benutzername"),
("pim_website", "Webseite"),
@@ -2202,6 +2241,7 @@ const FR: &[(&str, &str)] = &[
"pim_feeds_hint",
"Chaque lien public de calendrier et de carnet d'adresses sur ce serveur.",
),
("pim_file", "Fichier"),
("pim_from_organizer", "De {}"),
("pim_from_time", "à partir de {}"),
("pim_general", "Général"),
@@ -2238,8 +2278,25 @@ const FR: &[(&str, &str)] = &[
"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_new_hint_cal",
"Crée un agenda avec les événements d'un fichier .ics.",
),
(
"pim_import_new_hint_cards",
"Crée un carnet d'adresses avec les contacts d'un fichier .vcf.",
),
(
"pim_import_nothing",
"Rien n'a pu être importé, donc rien n'a été créé.",
),
("pim_import_skipped", "{} ignorés :"),
("pim_imported_as", "Importé sous « {} »."),
("pim_importing", "Importation…"),
(
"pim_inv_series_hint",
"Votre réponse vaut pour toute la série.",
),
("pim_invitations", "Invitations"),
("pim_kind_resource", "Ressource"),
("pim_kind_room", "Salle"),
@@ -2250,14 +2307,15 @@ const FR: &[(&str, &str)] = &[
("pim_links", "Liens publics"),
(
"pim_links_hint",
"Toute personne ayant le lien peut s'abonner. Google Agenda et Outlook.com exigent un lien sans mot de passe.",
"Toute personne ayant le lien peut s'abonner. Certains agendas, comme Google Agenda et Outlook.com, exigent un lien sans mot de passe.",
),
(
"pim_links_hint_cards",
"Toute personne ayant le lien peut télécharger les contacts une fois. Les applications ne suivent pas les modifications.",
),
("pim_load_more", "Afficher 30 jours de plus"),
("pim_location", "Lieu"),
("pim_manage", "Paramètres"),
("pim_manage_of", "Paramètres de {}"),
("pim_members", "Membres"),
("pim_mode_ro", "Lecture seule"),
("pim_mode_rw", "Lecture et écriture"),
@@ -2267,16 +2325,29 @@ const FR: &[(&str, &str)] = &[
),
("pim_month", "Mois"),
("pim_more_n", "+{} autres"),
("pim_name_from_file", "Tiré du fichier si vide"),
("pim_nav", "Calendrier et contacts"),
("pim_new_address_book", "Nouveau carnet d'adresses"),
(
"pim_new_book_from_file",
"Nouveau carnet d'adresses depuis un fichier",
),
("pim_new_calendar", "Nouveau calendrier"),
(
"pim_new_calendar_from_file",
"Nouvel agenda depuis un fichier",
),
("pim_new_from_file", "Nouveau depuis un fichier…"),
("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_agenda",
"Aucun événement dans les 7 prochains jours.",
),
("pim_no_events_day", "Aucun événement ce jour-là."),
("pim_no_feeds", "Aucun flux public."),
("pim_no_invitations", "Aucune invitation en attente."),
@@ -2338,7 +2409,6 @@ const FR: &[(&str, &str)] = &[
("pim_rrule_yearly_n", "Tous les {} ans"),
("pim_save_err", "Impossible d'enregistrer : {}"),
("pim_search_contacts", "Rechercher des contacts"),
("pim_series", "Série"),
("pim_server_address", "Adresse du serveur"),
(
"pim_server_address_hint",
@@ -2353,6 +2423,8 @@ const FR: &[(&str, &str)] = &[
"Partager avec un autre compte de ce serveur.",
),
("pim_show", "Afficher"),
("pim_show_all", "Tout afficher"),
("pim_show_only", "Afficher seulement celui-ci"),
("pim_skip_component", "ce type n'est pas accepté ici"),
("pim_skip_invalid", "données invalides ou incomplètes"),
("pim_skip_size", "trop volumineux"),
@@ -2366,7 +2438,7 @@ const FR: &[(&str, &str)] = &[
("pim_today", "Aujourd'hui"),
(
"pim_transparent",
"Afficher comme libre : ses événements ne bloquent pas de temps dans les invitations",
"Afficher comme libre : les événements ne bloquent pas de créneaux dans les invitations",
),
(
"pim_truncated",
@@ -2375,7 +2447,7 @@ const FR: &[(&str, &str)] = &[
("pim_unshare", "Ne plus partager"),
("pim_unshared", "N'est plus partagé"),
("pim_until_time", "jusqu'à {}"),
("pim_upcoming", "À venir"),
("pim_upcoming", "7 prochains jours"),
("pim_user", "Nom d'utilisateur"),
("pim_user_name", "Nom d'utilisateur"),
("pim_website", "Site web"),
Mweb/src/views/calendar.rs
@@ -523,8 +523,6 @@ pub fn CalendarMain(
});
});
// 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 |_| {
@@ -533,14 +531,16 @@ pub fn CalendarMain(
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;
let r = api::pim_instances(
&day_rfc3339(from),
&day_rfc3339(from + AGENDA_DAYS),
&browser_tz(),
)
.await;
if agenda_seq.get_value() == seq {
agenda.set(Some(r.map_err(|e| e.to_string())));
}
@@ -712,7 +712,7 @@ pub fn CalendarMain(
}
.into_any(),
CalView::Agenda => view! {
<AgendaView data=agenda days=agenda_days shown=shown/>
<AgendaView data=agenda shown=shown/>
}
.into_any(),
CalView::Invitations => view! {
@@ -985,10 +985,13 @@ fn MonthView(
}
}
/// The agenda shows today and the days after it. The month view is for
/// browsing further.
const AGENDA_DAYS: i64 = 7;
#[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! {
@@ -998,7 +1001,7 @@ fn AgendaView(
Ok(list) => {
let items = shown(&list);
let from = today();
let groups: Vec<_> = (from..from + days.get_untracked())
let groups: Vec<_> = (from..from + AGENDA_DAYS)
.filter_map(|day| {
let rows: Vec<_> = items
.iter()
@@ -1021,19 +1024,10 @@ fn AgendaView(
<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()
}
@@ -1128,9 +1122,12 @@ fn InvitationsView(
name: inv.name.clone(),
};
let span = span_of(&inv_instance(&inv));
let series = inv.recurring && inv.recurrence_id.is_none();
let mut meta = vec![fmt_span(&span)];
if inv.recurring && inv.recurrence_id.is_none() {
meta.push(i18n::t(k::PIM_SERIES).to_string());
match (&inv.rrule, &inv.recurrence_id) {
(Some(r), None) => meta.push(rrule_text(&with_start_day(r, span.first))),
(None, Some(_)) => meta.push(i18n::t(k::PIM_THIS_EVENT).to_string()),
_ => {}
}
if let Some(n) = names.get(&inv.collection_id) {
meta.push(n.clone());
@@ -1156,6 +1153,9 @@ fn InvitationsView(
{organizer.map(|n| view! {
<span class="cal-row-meta">{i18n::t_fmt(k::PIM_FROM_ORGANIZER, &n)}</span>
})}
{series.then(|| view! {
<span class="cal-row-meta">{i18n::t(k::PIM_INV_SERIES_HINT)}</span>
})}
</button>
<div class="cal-inv-actions">
{reply_buttons(None, move |ps| {
@@ -1183,7 +1183,7 @@ fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) {
}
}
/// `FREQ=WEEKLY;INTERVAL=2` as words; anything else stays raw.
/// `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` as words; an unknown FREQ stays raw.
fn rrule_text(rule: &str) -> String {
let part = |key: &str| {
rule.split(';')
@@ -1199,12 +1199,46 @@ fn rrule_text(rule: &str) -> String {
Some("YEARLY") => (k::PIM_RRULE_YEARLY, k::PIM_RRULE_YEARLY_N),
_ => return rule.to_string(),
};
match n {
let base = match n {
1 => i18n::t(keys.0).to_string(),
n => i18n::t_fmt(keys.1, &n.to_string()),
};
// Plain weekdays of a weekly rule, e.g. "Every week: Tue, Thu".
let days: Vec<String> = match part("FREQ") {
Some("WEEKLY") => part("BYDAY")
.unwrap_or_default()
.split(',')
.filter_map(weekday_name)
.collect(),
_ => Vec::new(),
};
match days.is_empty() {
true => base,
false => format!("{base}: {}", days.join(", ")),
}
}
/// A weekly rule without BYDAY repeats on its start's weekday. Naming it
/// makes "Every week" read "Every week: Tue".
fn with_start_day(rule: &str, first: i64) -> String {
let weekly = rule.split(';').any(|p| p == "FREQ=WEEKLY");
if !weekly || rule.split(';').any(|p| p.starts_with("BYDAY=")) {
return rule.to_string();
}
let code = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][weekday(first) as usize];
format!("{rule};BYDAY={code}")
}
/// `TU` as the locale's short weekday name. Numbered days (`2TU`) are skipped.
fn weekday_name(code: &str) -> Option<String> {
let offset = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
.iter()
.position(|&d| d == code)?;
// 2024-01-01 was a Monday.
let date = js_sys::Date::new_with_year_month_day(2024, 0, 1 + offset as i32);
Some(intl(&date, &[("weekday", "short")]))
}
fn person_name(p: &api_types::PimPerson) -> String {
p.name
.clone()
@@ -1526,6 +1560,20 @@ mod tests {
assert_eq!(first_chip.item, 0);
}
#[test]
fn a_weekly_rule_names_its_start_day() {
let friday = days_from_civil(2026, 10, 2);
assert_eq!(
with_start_day("FREQ=WEEKLY", friday),
"FREQ=WEEKLY;BYDAY=FR"
);
assert_eq!(
with_start_day("FREQ=WEEKLY;BYDAY=MO", friday),
"FREQ=WEEKLY;BYDAY=MO"
);
assert_eq!(with_start_day("FREQ=DAILY", friday), "FREQ=DAILY");
}
#[test]
fn a_bar_hidden_on_a_busy_day_splits() {
let week = grid_start(2026, 10, 1);
Mweb/src/views/contacts.rs
@@ -184,8 +184,15 @@ pub fn ContactsMain(
}
.into_any();
}
let colors: std::collections::HashMap<i64, String> = collections.with(|c| {
c.iter()
.flatten()
.map(|c| (c.id, safe_color(c.color.as_deref())))
.collect()
});
rows.into_iter()
.map(|c| {
let book = colors.get(&c.collection_id).cloned();
let key = (c.collection_id, c.name.clone());
let selected = open.as_ref() == Some(&key);
let param = format!("{}/{}", c.collection_id, c.name);
@@ -212,6 +219,9 @@ pub fn ContactsMain(
</span>
{(!meta.is_empty()).then(|| view! { <span class="ct-meta">{meta.join(" · ")}</span> })}
</span>
{book.map(|b| view! {
<span class="ct-book" style=format!("--swatch: {b}") aria-hidden="true"></span>
})}
</button>
}
})
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, PimShareInfo,
PimShareMode,
self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo,
PimShareCandidate, PimShareInfo, PimShareMode,
};
use crate::components::icon::{Icon, icon_svg};
use crate::components::modal::Modal;
@@ -25,10 +25,31 @@ 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.
/// No gray: the server paints its generated collections gray.
const PALETTE: [&str; 8] = [
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b",
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#f97316",
];
/// The first palette color no listed collection uses, so a new one stands
/// out. A collection without color shows the first palette color.
fn next_color(collections: &[PimCollectionInfo]) -> String {
let used: HashSet<String> = collections
.iter()
.filter(|c| !c.generated)
.map(|c| {
c.color
.as_deref()
.unwrap_or(PALETTE[0])
.to_ascii_lowercase()
})
.collect();
PALETTE
.iter()
.find(|p| !used.contains(**p))
.unwrap_or(&PALETTE[used.len() % PALETTE.len()])
.to_string()
}
/// The collections the lists do not show, by id.
const HIDDEN_KEY: &str = "dovenest.pim-hidden";
@@ -57,6 +78,19 @@ pub fn safe_color(c: Option<&str>) -> String {
}
}
/// Shows only `id` of the collections `ids`. On the only shown one, shows
/// them all again. Collections of the other kind keep their state.
fn solo(hidden: &mut HashSet<i64>, ids: &[i64], id: i64) {
let alone = !hidden.contains(&id) && ids.iter().all(|o| *o == id || hidden.contains(o));
for &o in ids {
if alone || o == id {
hidden.remove(&o);
} else {
hidden.insert(o);
}
}
}
/// The name to show: generated collections have translated names.
pub fn display_name(c: &PimCollectionInfo) -> String {
match (c.generated, c.kind) {
@@ -124,6 +158,22 @@ pub fn PimView(
});
});
reload.run(());
// An open event or contact must belong to a shown collection. Only a
// change of the shown set closes it, so a link to a hidden one still opens.
Effect::new(move |prev: Option<()>| {
let h = hidden.get();
if prev.is_none() {
return;
}
let open = loc.with_untracked(|l| {
l.open
.as_deref()
.and_then(|o| o.split('/').next()?.parse::<i64>().ok())
});
if open.is_some_and(|id| h.contains(&id)) {
crate::router::close_open();
}
});
let main = match kind {
PimCollectionKind::Calendar => {
let week_start = Signal::derive(move || {
@@ -168,12 +218,40 @@ fn CollectionList(
) -> impl IntoView {
let (managing, set_managing) = signal(Option::<PimCollectionInfo>::None);
let (creating, set_creating) = signal(false);
let (importing, set_importing) = signal(false);
let (connecting, set_connecting) = signal(false);
let calendars = kind == PimCollectionKind::Calendar;
let (title, new_label) = match calendars {
true => (k::PIM_CALENDARS, k::PIM_NEW_CALENDAR),
false => (k::PIM_ADDRESS_BOOKS, k::PIM_NEW_ADDRESS_BOOK),
};
let ids_of_kind = move || {
collections.with_untracked(|c| {
c.iter()
.flatten()
.filter(|c| c.kind == kind)
.map(|c| c.id)
.collect::<Vec<_>>()
})
};
let any_hidden = move || {
collections.with(|c| {
hidden.with(|h| {
c.iter()
.flatten()
.any(|c| c.kind == kind && h.contains(&c.id))
})
})
};
let show_all = move |_| {
let ids = ids_of_kind();
hidden.update(|h| {
for id in &ids {
h.remove(id);
}
save_hidden(h);
});
};
let row = move |c: PimCollectionInfo| {
let id = c.id;
let is_hidden = move || hidden.with(|h| h.contains(&id));
@@ -185,6 +263,13 @@ fn CollectionList(
save_hidden(h);
});
};
let only_this = move |_| {
let ids = ids_of_kind();
hidden.update(|h| {
solo(h, &ids, id);
save_hidden(h);
});
};
let note = match (c.generated, c.mode) {
(true, _) => Some(i18n::t(k::PIM_AUTO).to_string()),
(false, Some(_)) => Some(c.owner.clone()),
@@ -208,12 +293,20 @@ fn CollectionList(
</button>
<button
class="pim-row-main"
title=name.clone()
on:click=move |_| set_managing.set(Some(info.clone()))
title=i18n::tr(k::PIM_SHOW_ONLY)
on:click=only_this
>
<span class="pim-row-name">{name.clone()}</span>
{note.map(|n| view! { <span class="pim-row-note">{n}</span> })}
</button>
<button
class="icon-btn icon-btn-sm pim-row-gear"
title=i18n::tr(k::PIM_MANAGE)
aria-label=move || i18n::t_fmt(k::PIM_MANAGE_OF, &name)
on:click=move |_| set_managing.set(Some(info.clone()))
>
<Icon name=IconName::Settings class="ic-btn".to_string()/>
</button>
</div>
}
};
@@ -229,6 +322,14 @@ fn CollectionList(
>
<Icon name=IconName::Devices class="ic-btn".to_string()/>
</button>
<button
class="icon-btn icon-btn-sm"
title=i18n::tr(k::PIM_NEW_FROM_FILE)
aria-label=i18n::tr(k::PIM_NEW_FROM_FILE)
on:click=move |_| set_importing.set(true)
>
<Icon name=IconName::Upload class="ic-btn".to_string()/>
</button>
<button
class="icon-btn icon-btn-sm"
title=i18n::tr(new_label)
@@ -265,6 +366,11 @@ fn CollectionList(
{mine.into_iter().map(row).collect::<Vec<_>>()}
{lent_block}
</div>
<Show when=any_hidden>
<button class="btn btn-sm pim-show-all" on:click=show_all>
{i18n::tr(k::PIM_SHOW_ALL)}
</button>
</Show>
}
.into_any()
}
@@ -280,10 +386,19 @@ fn CollectionList(
<Show when=move || creating.get()>
<NewCollectionDialog
kind=kind
collections=collections
on_close=Callback::new(move |_| set_creating.set(false))
on_created=reload
/>
</Show>
<Show when=move || importing.get()>
<ImportNewDialog
kind=kind
collections=collections
on_close=Callback::new(move |_| set_importing.set(false))
on_created=reload
/>
</Show>
<Show when=move || connecting.get()>
<ConnectDialog
me=me
@@ -338,12 +453,15 @@ fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
#[component]
fn NewCollectionDialog(
kind: PimCollectionKind,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_close: Callback<()>,
on_created: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (name, set_name) = signal(String::new());
let color = RwSignal::new(Some(PALETTE[0].to_string()));
let color = RwSignal::new(Some(
collections.with_untracked(|c| next_color(c.as_deref().unwrap_or_default())),
));
let (description, set_description) = signal(String::new());
let (busy, set_busy) = signal(false);
let title = match kind {
@@ -576,16 +694,24 @@ fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
false => vec![PimShareMode::Ro, PimShareMode::Rw],
};
let (shares, set_shares) = signal(Option::<Vec<PimShareInfo>>::None);
let (candidates, set_candidates) = signal(Vec::<PimShareCandidate>::new());
let (user, set_user) = signal(String::new());
let (mode, set_mode) = signal(PimShareMode::Ro);
// Borrowers drop out of the candidates, so both lists reload together.
let load = move || {
spawn_local(async move {
match api::pim_shares(id).await {
Ok(v) => set_shares.set(Some(v)),
Ok(v) => {
let _ = set_shares.try_set(Some(v));
}
Err(e) => show_error(toast, e.to_string()),
}
if let Ok(v) = api::pim_share_candidates(id).await {
let _ = set_candidates.try_set(v);
}
});
};
let list_id = format!("pim-users-{id}");
load();
let set = move |who: String, m: PimShareMode| {
spawn_local(async move {
@@ -683,9 +809,25 @@ fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
placeholder=i18n::tr(k::PIM_USER_NAME)
aria-label=i18n::tr(k::PIM_USER_NAME)
autocomplete="off"
list=list_id.clone()
prop:value=move || user.get()
on:input=move |ev| set_user.set(event_target_value(&ev))
/>
<datalist id=list_id>
{move || {
candidates
.get()
.into_iter()
.map(|c| {
let label = match c.display_name.filter(|d| *d != c.name) {
Some(d) => format!("{d} ({})", c.name),
None => c.name.clone(),
};
view! { <option value=c.name>{label}</option> }
})
.collect::<Vec<_>>()
}}
</datalist>
<select
class="pim-mode"
aria-label=i18n::tr(k::PIM_ACCESS)
@@ -978,30 +1120,141 @@ fn ImportSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl Into
{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()), skip_reason(&s.reason))}</li>
})
.collect::<Vec<_>>()}
{(more > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &more.to_string())}</li> })}
</ul>
})}
}
})}
{move || result.get().map(import_summary)}
</section>
}
}
/// What an import created, updated and skipped, and why.
fn import_summary(r: PimImportResult) -> impl IntoView {
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()), skip_reason(&s.reason))}</li>
})
.collect::<Vec<_>>()}
{(more > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &more.to_string())}</li> })}
</ul>
})}
}
}
/// A file as a new calendar or address book. The name field is optional:
/// the server then takes the name the file gives itself, or the file name.
#[component]
fn ImportNewDialog(
kind: PimCollectionKind,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_close: Callback<()>,
on_created: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let input: NodeRef<leptos::html::Input> = NodeRef::new();
let (name, set_name) = signal(String::new());
let (busy, set_busy) = signal(false);
let (done, set_done) = signal(Option::<api::PimImportNew>::None);
let title = match kind {
PimCollectionKind::Calendar => k::PIM_NEW_CALENDAR_FROM_FILE,
PimCollectionKind::Addressbook => k::PIM_NEW_BOOK_FROM_FILE,
};
let submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let Some(file) = input
.get_untracked()
.and_then(|el| el.files())
.and_then(|list| list.get(0))
else {
return;
};
if busy.get() {
return;
}
set_busy.set(true);
let color = collections.with_untracked(|c| next_color(c.as_deref().unwrap_or_default()));
let n = name.get().trim().to_string();
spawn_local(async move {
match api::pim_import_new(kind, &n, &color, file).await {
Ok(r) => {
if r.collection.is_some() {
on_created.run(());
}
let _ = set_done.try_set(Some(r));
}
Err(e) => show_error(toast, e.to_string()),
}
let _ = set_busy.try_set(false);
});
};
view! {
<Modal class="pim-modal" on_close=on_close>
<h2 class="modal-title">{i18n::tr(title)}</h2>
{move || match done.get() {
None => view! {
<form on:submit=submit>
<p class="muted pim-hint">{i18n::tr(match kind {
PimCollectionKind::Calendar => k::PIM_IMPORT_NEW_HINT_CAL,
PimCollectionKind::Addressbook => k::PIM_IMPORT_NEW_HINT_CARDS,
})}</p>
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_FILE)}</span>
<input
node_ref=input
type="file"
class="field-input"
required=true
accept=".ics,.vcf,text/calendar,text/vcard"
/>
</label>
<label class="field">
<span class="field-label">{i18n::tr(k::NAME)}</span>
<input
class="field-input"
maxlength="200"
placeholder=i18n::tr(k::PIM_NAME_FROM_FILE)
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
<div class="modal-actions">
<button type="button" class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(k::CANCEL)}
</button>
<button type="submit" class="btn btn-primary" disabled=move || busy.get()>
{move || if busy.get() { i18n::t(k::PIM_IMPORTING) } else { i18n::t(k::PIM_IMPORT) }}
</button>
</div>
</form>
}
.into_any(),
Some(r) => {
let head = match &r.collection {
Some(c) => i18n::t_fmt(k::PIM_IMPORTED_AS, &c.name),
None => i18n::t(k::PIM_IMPORT_NOTHING).to_string(),
};
view! {
<p class="pim-hint">{head}</p>
{import_summary(r.result)}
<div class="modal-actions">
<button class="btn btn-primary" on:click=move |_| on_close.run(())>
{i18n::tr(k::CLOSE)}
</button>
</div>
}
.into_any()
}
}}
</Modal>
}
}
#[component]
fn DeleteSection(
info: PimCollectionInfo,
@@ -1156,3 +1409,55 @@ fn ConnectDialog(
</Modal>
}
}
#[cfg(test)]
mod tests {
use super::*;
fn col(id: i64, color: Option<&str>, generated: bool) -> PimCollectionInfo {
PimCollectionInfo {
id,
kind: PimCollectionKind::Calendar,
name: String::new(),
url: String::new(),
owner: String::new(),
mode: None,
generated,
color: color.map(str::to_string),
description: None,
components: Vec::new(),
transparent: false,
is_default: false,
shares: 0,
links: 0,
}
}
#[test]
fn a_new_collection_gets_an_unused_color() {
// No color counts as the first one; generated ones do not count.
let list = [
col(1, None, false),
col(2, Some("#22C55E"), false),
col(-1, Some("#f59e0b"), true),
];
assert_eq!(next_color(&list), "#f59e0b");
let full: Vec<_> = PALETTE.iter().map(|c| col(1, Some(c), false)).collect();
assert_eq!(next_color(&full), PALETTE[0]);
}
#[test]
fn solo_toggles_between_one_and_all() {
let ids = [1, 2, 3];
let mut h = HashSet::from([9]);
solo(&mut h, &ids, 2);
assert_eq!(h, HashSet::from([1, 3, 9]));
// Again on the only shown one: all of this kind shown, 9 untouched.
solo(&mut h, &ids, 2);
assert_eq!(h, HashSet::from([9]));
// From a partly hidden list, a click shows just that one.
let mut h = HashSet::from([1]);
solo(&mut h, &ids, 1);
assert_eq!(h, HashSet::from([2, 3]));
}
}