api.rs
⎇
Raw
1//! Typed HTTP client for the dovenest API.
2//!
3//! Endpoint paths, query params and wire types all come from the shared
4//! `api_types` crate (the same one the server's route table and handlers
5//! use), so the two sides cannot drift apart.
6
7use leptos::prelude::Callable;
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use wasm_bindgen::JsCast;
11use wasm_bindgen::JsValue;
12use wasm_bindgen::closure::Closure;
13use wasm_bindgen_futures::JsFuture;
14
15use api_types::{
16 ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_EXISTS, ACTION_MKDIR,
17 ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS,
18 AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
19 AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateAppPassword,
20 CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq,
21 Mutation, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_PATH,
22 P_Q, P_ROOT, P_SCOPE, P_SHARE, P_SORT, PasskeyLoginBegin, PasskeyLoginFinish,
23 PasskeyRegisterFinish, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings,
24 SortKey, UnlockShare, UpdateUser,
25};
26use api_types::{
27 ADMIN_PIM_LINKS, ADMIN_ROOMS, CANDIDATES_SUFFIX, CreatePimCollection, CreatePimLink,
28 CreatePimShare, CreateRoom, EXPORT_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, P_FROM,
29 P_RECURRENCE_ID, P_TO, P_TZ, PHOTO_SUFFIX, PIM_COLLECTIONS, PIM_CONTACTS, PIM_IMPORT_NEW,
30 PIM_INSTANCES, PIM_INVITATIONS, PIM_SHARES, SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
31};
32pub use api_types::{
33 AdminPimLink, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail,
34 PimEventDetail, PimImportNew, PimImportResult, PimInstance, PimInstances, PimInvitation,
35 PimLend, PimLinkInfo, PimObjectDetail, PimOwnShares, PimReply, PimShareCandidate, PimShareInfo,
36 PimShareMode, RoomInfo, RoomKind,
37};
38pub use api_types::{
39 AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
40 Mode, NewAppPassword, OkResp, Op, PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo,
41 SaveResp, ShareInfo, UserInfo,
42};
43
44#[derive(Debug)]
45pub enum ApiError {
46 /// Non-2xx response. `skipped` carries the server's conflict file list
47 /// when present (upload conflicts).
48 Http {
49 #[allow(dead_code)]
50 status: u16,
51 message: String,
52 skipped: Option<Vec<String>>,
53 },
54 /// The file changed on disk since it was read (save conflict, HTTP 409).
55 Conflict,
56 /// The request never got a response (server down, connection lost) or
57 /// the response could not be used. Carries a message ready to display.
58 Net(String),
59}
60
61impl std::fmt::Display for ApiError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 ApiError::Http { message: m, .. } | ApiError::Net(m) => f.write_str(m),
65 ApiError::Conflict => f.write_str("the file was changed on disk"),
66 }
67 }
68}
69
70/// A JS error's `message` (the whole `Debug` output includes the stack).
71fn js_msg(e: &JsValue) -> String {
72 e.dyn_ref::<js_sys::Error>()
73 .map(|e| String::from(e.message()))
74 .unwrap_or_else(|| format!("{e:?}"))
75}
76
77impl ApiError {
78 pub fn skipped(&self) -> Option<&[String]> {
79 match self {
80 ApiError::Http {
81 skipped: Some(s), ..
82 } => Some(s),
83 _ => None,
84 }
85 }
86
87 /// The HTTP status code, when this was an HTTP (non-2xx) error.
88 pub fn status(&self) -> Option<u16> {
89 match self {
90 ApiError::Http { status, .. } => Some(*status),
91 _ => None,
92 }
93 }
94}
95
96/// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`.
97/// The message is already localized in [`fetch_checked`]; `code` carries the
98/// machine-readable identifier the server sends for known failures.
99#[derive(serde::Deserialize, Default)]
100struct ErrBody {
101 #[serde(default)]
102 error: Option<String>,
103 #[serde(default)]
104 code: Option<String>,
105 #[serde(default)]
106 skipped: Option<Vec<String>>,
107}
108
109impl ErrBody {
110 /// The error for a non-2xx `status`. Known server errors carry a code
111 /// the client maps to a localized message; unknown ones fall back to the
112 /// raw text.
113 fn into_error(self, status: u16, fallback: &str) -> ApiError {
114 let fallback = self.error.as_deref().unwrap_or(fallback);
115 ApiError::Http {
116 status,
117 message: crate::i18n::error_text(self.code.as_deref(), fallback),
118 skipped: self.skipped,
119 }
120 }
121}
122
123// ---------------------------------------------------------------------------
124// Auth
125// ---------------------------------------------------------------------------
126
127pub async fn me() -> Result<Me, ApiError> {
128 let me: Me = request("GET", AUTH_ME.to_string(), None::<()>).await?;
129 crate::router::set_public_url(me.public_url.clone());
130 Ok(me)
131}
132
133/// PUT /api/auth/me — update the signed-in user's profile settings.
134/// Omitted fields are left unchanged; `language: Some(None)` means "follow
135/// the browser".
136#[derive(Serialize, Default)]
137struct ProfilePatch {
138 #[serde(skip_serializing_if = "Option::is_none")]
139 single_click_open: Option<bool>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 thumbnails: Option<bool>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 language: Option<Option<String>>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 default_root_id: Option<Option<i64>>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 week_start: Option<u8>,
148}
149
150pub fn update_profile(
151 single_click_open: Option<bool>,
152 thumbnails: Option<bool>,
153 language: Option<Option<String>>,
154 default_root_id: Option<Option<i64>>,
155 week_start: Option<u8>,
156) -> impl std::future::Future<Output = Result<Me, ApiError>> {
157 request(
158 "PUT",
159 AUTH_ME.to_string(),
160 Some(ProfilePatch {
161 single_click_open,
162 thumbnails,
163 language,
164 default_root_id,
165 week_start,
166 }),
167 )
168}
169
170/// The password leg of signing in.
171///
172/// `state_id` names a passkey leg that already identified the account, which
173/// is how an account requiring both factors finishes when the user starts
174/// with the passkey. Then `name` is not needed and is ignored.
175pub fn login(
176 name: Option<String>,
177 password: String,
178 state_id: Option<String>,
179) -> impl std::future::Future<Output = Result<LoginResp, ApiError>> {
180 request(
181 "POST",
182 AUTH_LOGIN.to_string(),
183 Some(LoginReq {
184 name,
185 password,
186 state_id,
187 }),
188 )
189}
190
191// ---------------------------------------------------------------------------
192// Credentials: password, sign-in mode, passkeys
193// ---------------------------------------------------------------------------
194
195pub fn change_password(
196 new_password: String,
197) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
198 request(
199 "POST",
200 AUTH_PASSWORD.to_string(),
201 Some(ChangePassword { new_password }),
202 )
203}
204
205pub fn delete_password() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
206 request("DELETE", AUTH_PASSWORD.to_string(), None::<()>)
207}
208
209pub fn set_auth_mode(
210 mode: AuthMode,
211) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
212 request("PUT", AUTH_MODE.to_string(), Some(SetAuthMode { mode }))
213}
214
215pub fn list_passkeys() -> impl std::future::Future<Output = Result<Vec<PasskeyInfo>, ApiError>> {
216 request("GET", AUTH_PASSKEYS.to_string(), None::<()>)
217}
218
219pub fn delete_passkey(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
220 request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>)
221}
222
223pub fn list_app_passwords()
224-> impl std::future::Future<Output = Result<Vec<AppPasswordInfo>, ApiError>> {
225 request("GET", AUTH_APP_PASSWORDS.to_string(), None::<()>)
226}
227
228pub fn create_app_password(
229 name: String,
230) -> impl std::future::Future<Output = Result<NewAppPassword, ApiError>> {
231 request(
232 "POST",
233 AUTH_APP_PASSWORDS.to_string(),
234 Some(CreateAppPassword { name }),
235 )
236}
237
238pub fn delete_app_password(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
239 request("DELETE", format!("{AUTH_APP_PASSWORDS}/{id}"), None::<()>)
240}
241
242/// Register a passkey end to end: ask for a challenge, hand it to the
243/// browser, send the answer back.
244///
245/// One function rather than two calls at the view layer, because the two legs
246/// are useless apart and the handle between them is not the view's business.
247pub async fn add_passkey(name: String) -> Result<PasskeyInfo, ApiError> {
248 let challenge: PasskeyChallenge =
249 request("POST", AUTH_PASSKEYS_REGISTER.to_string(), None::<()>).await?;
250 let credential = crate::passkey::create(&challenge.options)
251 .await
252 .map_err(ApiError::Net)?;
253 request(
254 "POST",
255 format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"),
256 Some(PasskeyRegisterFinish {
257 state_id: challenge.state_id,
258 name,
259 credential,
260 }),
261 )
262 .await
263}
264
265/// Sign in with a passkey.
266///
267/// `Ok(None)` means the browser request was cancelled to make room for
268/// another one — nothing happened, and nothing should be shown.
269pub async fn passkey_login(
270 name: Option<String>,
271 conditional: bool,
272) -> Result<Option<LoginResp>, ApiError> {
273 let challenge: PasskeyChallenge = request(
274 "POST",
275 AUTH_PASSKEY_LOGIN.to_string(),
276 Some(PasskeyLoginBegin { name, conditional }),
277 )
278 .await?;
279 passkey_finish(challenge, conditional).await
280}
281
282/// Answer a challenge the server already handed out: the second factor of a
283/// password sign-in arrives inside the login response, so that leg has no
284/// begin call of its own.
285pub async fn passkey_finish(
286 challenge: PasskeyChallenge,
287 conditional: bool,
288) -> Result<Option<LoginResp>, ApiError> {
289 let Some(credential) = crate::passkey::get(&challenge.options, conditional)
290 .await
291 .map_err(ApiError::Net)?
292 else {
293 return Ok(None);
294 };
295 request(
296 "POST",
297 format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"),
298 Some(PasskeyLoginFinish {
299 state_id: challenge.state_id,
300 credential,
301 }),
302 )
303 .await
304 .map(Some)
305}
306
307pub fn setup(
308 name: String,
309 password: String,
310) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
311 request(
312 "POST",
313 AUTH_SETUP.to_string(),
314 Some(Credentials { name, password }),
315 )
316}
317
318pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
319 request("POST", AUTH_LOGOUT.to_string(), Some(()))
320}
321
322// ---------------------------------------------------------------------------
323// Files
324// ---------------------------------------------------------------------------
325
326/// The public share token while the app is showing a share page. Every file
327/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
328/// shown per page, so a plain static suffices (wasm is single-threaded).
329use std::sync::Mutex;
330static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
331
332/// Set (or clear, with `None`) the share token used by file API calls.
333pub fn set_share_token(token: Option<&str>) {
334 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
335}
336
337fn share_suffix() -> String {
338 SHARE_TOKEN
339 .lock()
340 .unwrap()
341 .as_deref()
342 .map(|t| format!("?{P_SHARE}={t}"))
343 .unwrap_or_default()
344}
345
346/// Append `key=value` to a URL, using `&` when a query string already exists.
347fn append_query(url: &str, kv: &str) -> String {
348 if url.contains('?') {
349 format!("{url}&{kv}")
350 } else {
351 format!("{url}?{kv}")
352 }
353}
354
355fn files_url(root_id: i64, path: &str) -> String {
356 let base = if path.is_empty() {
357 format!("{FILES}/{root_id}")
358 } else {
359 let encoded: Vec<String> = path
360 .split('/')
361 .map(|s| js_sys::encode_uri_component(s).into())
362 .collect();
363 format!("{FILES}/{root_id}/{}", encoded.join("/"))
364 };
365 format!("{base}{}", share_suffix())
366}
367
368/// One page of a folder, in the given order. With `around`, the page that
369/// holds that name.
370pub fn list_files(
371 root_id: i64,
372 path: &str,
373 sort: SortKey,
374 desc: bool,
375 offset: usize,
376 limit: usize,
377 around: Option<&str>,
378) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
379 let mut query = format!(
380 "{P_SORT}={}&{P_DESC}={desc}&{P_OFFSET}={offset}&{P_LIMIT}={limit}",
381 sort.as_str()
382 );
383 if let Some(name) = around {
384 query.push_str(&format!(
385 "&{P_AROUND}={}",
386 String::from(js_sys::encode_uri_component(name))
387 ));
388 }
389 request(
390 "GET",
391 append_query(&files_url(root_id, path), &query),
392 None::<()>,
393 )
394}
395
396/// One entry of a folder, with its sniffed kind. `None` when it is gone.
397pub async fn find_entry(root_id: i64, dir: &str, name: &str) -> Result<Option<Entry>, ApiError> {
398 let r = list_files(root_id, dir, SortKey::Name, false, 0, 1, Some(name)).await?;
399 Ok(r.entries.into_iter().find(|e| e.name == name))
400}
401
402/// The subfolders of a folder, by name.
403pub fn list_dirs(
404 root_id: i64,
405 path: &str,
406) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
407 let url = append_query(&files_url(root_id, path), &format!("{P_DIRS}=true"));
408 request("GET", url, None::<()>)
409}
410
411/// Create an empty file. `path` is relative to the root (may contain
412/// subfolders); the file must not exist yet.
413pub fn create_file(
414 root_id: i64,
415 path: &str,
416) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
417 let url = append_query(
418 &files_url(root_id, path),
419 &format!("{P_ACTION}={ACTION_CREATE_FILE}"),
420 );
421 request("POST", url, None::<()>)
422}
423
424/// Create a folder. `path` is relative to the root (may contain subfolders).
425pub fn mkdir(
426 root_id: i64,
427 path: &str,
428) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
429 let url = append_query(
430 &files_url(root_id, path),
431 &format!("{P_ACTION}={ACTION_MKDIR}"),
432 );
433 request("POST", url, None::<()>)
434}
435
436pub fn rename_item(
437 root_id: i64,
438 path: &str,
439 new_name: String,
440 overwrite: bool,
441) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
442 request(
443 "POST",
444 files_url(root_id, path),
445 Some(Mutation {
446 op: Op::Rename,
447 new_name: Some(new_name),
448 dst_root_id: None,
449 dst: None,
450 overwrite,
451 }),
452 )
453}
454
455/// Move or copy an item into `dst` of `dst_root_id`.
456pub fn mutation(
457 root_id: i64,
458 path: &str,
459 op: Op,
460 dst_root_id: i64,
461 dst: &str,
462 overwrite: bool,
463) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
464 request(
465 "POST",
466 files_url(root_id, path),
467 Some(Mutation {
468 op,
469 new_name: None,
470 dst_root_id: Some(dst_root_id),
471 dst: Some(dst.to_string()),
472 overwrite,
473 }),
474 )
475}
476
477pub fn delete_item(
478 root_id: i64,
479 path: &str,
480) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
481 request("DELETE", files_url(root_id, path), None::<()>)
482}
483
484// ---------------------------------------------------------------------------
485// Download / preview / content (milestone 4)
486// ---------------------------------------------------------------------------
487
488/// `...?action=download` — a single file as-is, or a folder as `format`.
489pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
490 let mut base = append_query(
491 &files_url(root_id, path),
492 &format!("{P_ACTION}={ACTION_DOWNLOAD}"),
493 );
494 if let Some(f) = format {
495 base = append_query(&base, &format!("{P_FORMAT}={f}"));
496 }
497 base
498}
499
500/// `...?action=preview` — a single file, inline (native media).
501pub fn preview_url(root_id: i64, path: &str) -> String {
502 append_query(
503 &files_url(root_id, path),
504 &format!("{P_ACTION}={ACTION_PREVIEW}"),
505 )
506}
507
508/// `...?action=thumb` — a small WebP for the grid.
509///
510/// `mtime` is in the query so a changed file is a new URL. The server marks
511/// the response immutable, which depends on that.
512pub fn thumb_url(root_id: i64, path: &str, mtime: &str) -> String {
513 append_query(
514 &files_url(root_id, path),
515 &format!(
516 "{P_ACTION}={ACTION_THUMB}&v={}",
517 js_sys::encode_uri_component(mtime)
518 ),
519 )
520}
521
522/// `...?action=content` — raw file bytes for the text preview/editor.
523pub fn content_url(root_id: i64, path: &str) -> String {
524 append_query(
525 &files_url(root_id, path),
526 &format!("{P_ACTION}={ACTION_CONTENT}"),
527 )
528}
529
530/// Fetch a file's raw text content plus its mtime (unix seconds), for the
531/// preview and the editor. The mtime anchors the save-time conflict check.
532pub async fn fetch_content_meta(
533 root_id: i64,
534 path: &str,
535) -> Result<(String, Option<i64>), ApiError> {
536 let opts = web_sys::RequestInit::new();
537 opts.set_method("GET");
538 opts.set_mode(web_sys::RequestMode::SameOrigin);
539 let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
540 let mtime: Option<i64> = resp
541 .headers()
542 .get("x-file-mtime")
543 .ok()
544 .flatten()
545 .and_then(|s| s.parse::<i64>().ok());
546 let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?;
547 let js = JsFuture::from(tp)
548 .await
549 .map_err(|e| ApiError::Net(js_msg(&e)))?;
550 let text = js
551 .as_string()
552 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
553 Ok((text, mtime))
554}
555
556/// Save a file's text content (the editor's write path).
557///
558/// When `force` is false, `expected_mtime` is sent and the server rejects the
559/// save with [`ApiError::Conflict`] if the file changed on disk since it was
560/// read. When `force` is true the check is skipped (overwrite). Returns the
561/// file's new mtime (unix seconds) to anchor the next check.
562pub async fn save_content(
563 root_id: i64,
564 path: &str,
565 text: &str,
566 expected_mtime: Option<i64>,
567 force: bool,
568) -> Result<i64, ApiError> {
569 let url = content_url(root_id, path);
570 let opts = web_sys::RequestInit::new();
571 opts.set_method("PUT");
572 opts.set_mode(web_sys::RequestMode::SameOrigin);
573 opts.set_body_opt_str(Some(text));
574 let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
575 headers
576 .set("Content-Type", "text/plain; charset=utf-8")
577 .map_err(|e| ApiError::Net(js_msg(&e)))?;
578 if !force && let Some(m) = expected_mtime {
579 headers
580 .set("X-Expected-Mtime", &m.to_string())
581 .map_err(|e| ApiError::Net(js_msg(&e)))?;
582 }
583 opts.set_headers_headers(&headers);
584 // 409 is the server's "changed on disk" answer, not a generic HTTP error.
585 let resp = match fetch_checked(&url, &opts, "could not save file").await {
586 Ok(resp) => resp,
587 Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
588 Err(e) => return Err(e),
589 };
590 let save: SaveResp = read_json(&resp).await?;
591 Ok(save.mtime)
592}
593
594/// Open a URL in a new tab.
595///
596/// `noopener` severs the `window.opener` link, so the opened page cannot
597/// script this one. That matters here because the target is a *user file*:
598/// HTML and SVG render as real documents (under the server's sandbox CSP, see
599/// `FILE_CSP`), and this is the browser-side half of the same isolation.
600pub fn open_in_new_tab(url: &str) {
601 if let Some(w) = web_sys::window() {
602 let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener");
603 }
604}
605
606/// Trigger a browser download of a same-origin URL via a temporary anchor.
607/// No data is pulled into JS memory — the browser streams it.
608pub fn trigger_download(url: &str, filename: &str) {
609 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
610 return;
611 };
612 let Ok(el) = doc.create_element("a") else {
613 return;
614 };
615 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
616 return;
617 };
618 a.set_href(url);
619 a.set_download(filename);
620 if let Some(body) = doc.body() {
621 let _ = body.append_child(&a);
622 }
623 a.click();
624 a.remove();
625}
626
627/// Build a multipart body by hand into a `Blob` (instead of using
628/// `FormData` directly as the request body): a FormData body makes Chrome
629/// send the request as a *streaming* body, which forces the HTTP/2-cleartext
630/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
631/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
632/// it goes out as a regular length-prefixed HTTP/1.1 request.
633///
634/// Returns the body and its `Content-Type` header value.
635fn multipart_blob(parts: &[(String, web_sys::File)]) -> Result<(web_sys::Blob, String), ApiError> {
636 let boundary = format!("----dovenest{}", random_boundary_suffix());
637 let segments = js_sys::Array::new();
638 for (name, file) in parts {
639 let basename = escape_cd(name.rsplit('/').next().unwrap_or(name));
640 let name = escape_cd(name);
641 segments.push(&JsValue::from_str(&format!(
642 "--{boundary}\r\n\
643 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
644 Content-Type: application/octet-stream\r\n\r\n"
645 )));
646 let f: JsValue = file.clone().unchecked_into();
647 segments.push(&f);
648 segments.push(&JsValue::from_str("\r\n"));
649 }
650 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
651 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
652 .map_err(|e| ApiError::Net(js_msg(&e)))?;
653 Ok((body, format!("multipart/form-data; boundary={boundary}")))
654}
655
656/// Escape a multipart part name per the WHATWG form-data rules. The three
657/// characters that would break out of the quoted `Content-Disposition`
658/// value are percent-encoded; the server decodes them back.
659fn escape_cd(s: &str) -> String {
660 s.replace('"', "%22")
661 .replace('\r', "%0D")
662 .replace('\n', "%0A")
663}
664
665/// Read-only upload pre-check: which of `paths` (relative to `dir`, may
666/// contain subfolders) already exist, and whether each is a folder.
667pub async fn check_exists(
668 root_id: i64,
669 dir: &str,
670 paths: Vec<String>,
671) -> Result<Vec<Existing>, ApiError> {
672 let url = append_query(
673 &files_url(root_id, dir),
674 &format!("{P_ACTION}={ACTION_EXISTS}"),
675 );
676 // The server caps one request at 10 000 paths, the JSON body at 1 MB.
677 // A folder upload can bring far more (a kernel tree is ~80 000 files).
678 // Path length varies a lot, so the chunks are cut by bytes as well.
679 const CHUNK_BYTES: usize = 900_000;
680 const CHUNK_PATHS: usize = 10_000;
681 let mut existing = Vec::new();
682 let mut chunk: Vec<String> = Vec::new();
683 let mut bytes = 0usize;
684 for p in paths {
685 // Quotes, comma and escaping headroom around each path in the JSON.
686 bytes += p.len() + 8;
687 chunk.push(p);
688 if bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_PATHS {
689 existing.extend(exists_chunk(&url, std::mem::take(&mut chunk)).await?);
690 bytes = 0;
691 }
692 }
693 if !chunk.is_empty() {
694 existing.extend(exists_chunk(&url, chunk).await?);
695 }
696 Ok(existing)
697}
698
699async fn exists_chunk(url: &str, paths: Vec<String>) -> Result<Vec<Existing>, ApiError> {
700 let resp: ExistsResp = request("POST", url.to_string(), Some(ExistsReq { paths })).await?;
701 Ok(resp.existing)
702}
703
704/// Upload `files` into `dir` in one request, each as `(relative path,
705/// file)`; subfolders are created on the server. The returned request can be
706/// `abort()`ed; the future then resolves to [`ApiError::Net`], the same as a
707/// lost connection. `on_progress` gets the file bytes sent so far (multipart
708/// framing excluded). `overwrite=false` makes the server skip files that
709/// exist and list them in a 409 after writing the rest; those are the files
710/// that appeared during the transfer, since the pre-check covered the ones
711/// that existed before.
712pub fn start_upload(
713 root_id: i64,
714 dir: &str,
715 files: Vec<(String, web_sys::File)>,
716 overwrite: bool,
717 on_progress: impl Fn(f64) + 'static,
718) -> Result<
719 (
720 web_sys::XmlHttpRequest,
721 impl std::future::Future<Output = Result<(), ApiError>>,
722 ),
723 ApiError,
724> {
725 let size: f64 = files.iter().map(|(_, f)| f.size()).sum();
726 let (body, content_type) = multipart_blob(&files)?;
727 let url = append_query(
728 &files_url(root_id, dir),
729 &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
730 );
731 let xhr = web_sys::XmlHttpRequest::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
732 xhr.open_with_async("POST", &url, true)
733 .map_err(|e| ApiError::Net(js_msg(&e)))?;
734 xhr.set_request_header("Content-Type", &content_type)
735 .map_err(|e| ApiError::Net(js_msg(&e)))?;
736
737 let progress =
738 Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |ev: web_sys::ProgressEvent| {
739 // `loaded` counts the whole multipart body, boundaries included.
740 // Scale it to file bytes so a tiny file never reads as 600 %.
741 let total = ev.total();
742 let file_bytes = if total > 0.0 {
743 (ev.loaded() / total * size).min(size)
744 } else {
745 ev.loaded().min(size)
746 };
747 on_progress(file_bytes);
748 });
749 if let Ok(up) = xhr.upload() {
750 up.set_onprogress(Some(progress.as_ref().unchecked_ref()));
751 }
752 // `loadend` fires for success, error and abort alike; the status tells
753 // them apart afterwards.
754 let done = js_sys::Promise::new(&mut |resolve, _reject| {
755 xhr.set_onloadend(Some(&resolve));
756 });
757 let req = xhr.clone();
758 xhr.send_with_opt_blob(Some(&body))
759 .map_err(|e| ApiError::Net(js_msg(&e)))?;
760
761 let fut = async move {
762 let _ = JsFuture::from(done).await;
763 // Keep the progress listener alive until here.
764 drop(progress);
765 let status = xhr.status().unwrap_or(0);
766 if status == 0 {
767 // Our own abort and a dead network are indistinguishable here:
768 // both give status 0 and an empty status text. Report the
769 // network error; the caller knows whether it aborted.
770 return Err(ApiError::Net(
771 crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string(),
772 ));
773 }
774 if (200..300).contains(&status) {
775 return Ok(());
776 }
777 let body: ErrBody = xhr
778 .response_text()
779 .ok()
780 .flatten()
781 .and_then(|t| serde_json::from_str(&t).ok())
782 .unwrap_or_default();
783 Err(body.into_error(status, "upload failed"))
784 };
785 Ok((req, fut))
786}
787
788// ---------------------------------------------------------------------------
789// Shares (milestone 6)
790// ---------------------------------------------------------------------------
791
792pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
793 request("GET", SHARES.to_string(), None::<()>)
794}
795
796pub fn create_share(
797 root_id: i64,
798 path: &str,
799 writable: bool,
800 expires_at: Option<&str>,
801 password: Option<&str>,
802) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
803 request(
804 "POST",
805 SHARES.to_string(),
806 Some(CreateShare {
807 root_id,
808 path: path.to_string(),
809 writable,
810 expires_at: expires_at.map(|s| s.to_string()),
811 password: password.map(|s| s.to_string()),
812 }),
813 )
814}
815
816pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
817 request("DELETE", format!("{SHARES}/{id}"), None::<()>)
818}
819
820/// Admin only: every share on the server with its creator.
821pub fn list_all_shares() -> impl std::future::Future<Output = Result<Vec<AdminShare>, ApiError>> {
822 request("GET", ADMIN_SHARES.to_string(), None::<()>)
823}
824
825/// Admin only: end a share whoever created it.
826pub fn admin_delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
827 request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>)
828}
829
830/// Public: resolve a share (no session required). A password-protected
831/// share answers 401 until [`unlock_share`] has run in this browser.
832pub fn resolve_share(
833 token: &str,
834) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
835 request("GET", format!("{SHARE}/{token}"), None::<()>)
836}
837
838/// Public: submit a protected share's password. The proof of the unlock is
839/// a cookie the server sets, so nothing has to be kept here.
840pub fn unlock_share(
841 token: &str,
842 password: &str,
843) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
844 request(
845 "POST",
846 format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
847 Some(UnlockShare {
848 password: password.to_string(),
849 }),
850 )
851}
852
853// ---------------------------------------------------------------------------
854// Admin (milestone 7): user management + settings
855// ---------------------------------------------------------------------------
856
857fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
858 roots
859 .iter()
860 .map(|(path, mode)| Root {
861 path: path.clone(),
862 mode: *mode,
863 })
864 .collect()
865}
866
867pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
868 request("GET", ADMIN_USERS.to_string(), None::<()>)
869}
870
871pub fn create_admin_user(
872 name: &str,
873 password: &str,
874 is_admin: bool,
875 roots: &[(String, Mode)],
876) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
877 request(
878 "POST",
879 ADMIN_USERS.to_string(),
880 Some(CreateUser {
881 name: name.to_string(),
882 password: password.to_string(),
883 is_admin,
884 roots: roots_to_bodies(roots),
885 }),
886 )
887}
888
889pub fn update_admin_user(
890 id: i64,
891 password: Option<String>,
892 is_admin: Option<bool>,
893 active: Option<bool>,
894 roots: Option<Vec<(String, Mode)>>,
895) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
896 request(
897 "PUT",
898 format!("{ADMIN_USERS}/{id}"),
899 Some(UpdateUser {
900 password,
901 is_admin,
902 active,
903 roots: roots.map(|r| roots_to_bodies(&r)),
904 }),
905 )
906}
907
908pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
909 request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
910}
911
912pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
913 request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
914}
915
916/// PUT replaces the whole settings object, so every setting has to be
917/// passed. Omitting one would reset it.
918pub fn update_admin_settings(
919 allow_writable_shares: bool,
920 search_excludes: Vec<String>,
921) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
922 request(
923 "PUT",
924 ADMIN_SETTINGS.to_string(),
925 Some(Settings {
926 allow_writable_shares,
927 search_excludes,
928 }),
929 )
930}
931
932// ---------------------------------------------------------------------------
933// File picker (imperative, one at a time)
934// ---------------------------------------------------------------------------
935
936fn random_boundary_suffix() -> String {
937 let mut s = String::with_capacity(16);
938 for _ in 0..16 {
939 let n = (js_sys::Math::random() * 36.0) as u32;
940 s.push(char::from_digit(n, 36).unwrap_or('a'));
941 }
942 s
943}
944
945/// Open the native file dialog and run `on_files` with the picked files once
946/// the user confirms. `directory` uses webkitdirectory (folder upload).
947fn webkit_relative_path(file: &web_sys::File) -> String {
948 let js: JsValue = file.into();
949 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
950 .ok()
951 .and_then(|v| v.as_string())
952 .filter(|s| !s.is_empty())
953 .unwrap_or_default()
954}
955
956pub fn pick_files(
957 multiple: bool,
958 directory: bool,
959 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
960) {
961 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
962 return;
963 };
964 let Ok(el) = doc.create_element("input") else {
965 return;
966 };
967 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
968 return;
969 };
970 input.set_type("file");
971 // Off screen: the browser renders a bare "Choose files" control for an
972 // input in the body, and `click()` still works on a hidden one.
973 let _ = input.set_attribute("hidden", "");
974 if multiple {
975 input.set_multiple(true);
976 }
977 if directory {
978 let _ = input.set_attribute("webkitdirectory", "");
979 }
980
981 // Known small leak, deliberate: the input holds the listener, the
982 // listener holds this closure, and the closure captures the input — a
983 // cycle that nothing frees. `forget()` detaches the Rust side, so the
984 // element + JS function + closure (~1 KB) survive until reload even
985 // when the dialog is used (not only when cancelled). Dropping the
986 // closure from Rust while the JS listener still references it would
987 // leave a dangling callback, and a closure cannot drop itself; a clean
988 // fix needs the caller to own it (e.g. a `StoredValue` released in
989 // `on_cleanup`), which is not worth it at this size.
990 let input2 = input.clone();
991 let closure = Closure::<dyn FnMut()>::new(move || {
992 let mut files: Vec<(String, web_sys::File)> = Vec::new();
993 if let Some(list) = input.files() {
994 for i in 0..list.length() {
995 if let Some(f) = list.get(i) {
996 let rel = webkit_relative_path(&f);
997 let name = if rel.is_empty() { f.name() } else { rel };
998 files.push((name, f));
999 }
1000 }
1001 }
1002 input.remove();
1003 if !files.is_empty() {
1004 on_files(files);
1005 }
1006 });
1007 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
1008 let _ = input2.add_event_listener_with_callback("change", listener);
1009 closure.forget();
1010 if let Some(body) = doc.body() {
1011 let _ = body.append_child(&input2);
1012 }
1013 input2.click();
1014}
1015
1016/// True when a drag carries files (not text or a link from the page).
1017pub fn drag_has_files(ev: &web_sys::DragEvent) -> bool {
1018 ev.data_transfer()
1019 .map(|dt| dt.types().includes(&JsValue::from_str("Files"), 0))
1020 .unwrap_or(false)
1021}
1022
1023/// The top-level items of a drop, read out of the `DataTransfer` while the
1024/// drop handler still runs. A `DataTransfer` is only readable during its own
1025/// event, so an async task that reads it later finds it empty. [`read_drop`]
1026/// therefore copies the entries and files out first.
1027pub struct Dropped {
1028 entries: Vec<web_sys::FileSystemEntry>,
1029 /// Flat list, for browsers without the entries API.
1030 files: Vec<web_sys::File>,
1031}
1032
1033impl Dropped {
1034 /// Names of the top-level items, for a job label before the folders are
1035 /// walked. A dropped folder shows up as one name here.
1036 pub fn names(&self) -> Vec<String> {
1037 if self.entries.is_empty() {
1038 self.files.iter().map(|f| f.name()).collect()
1039 } else {
1040 self.entries.iter().map(|e| e.name()).collect()
1041 }
1042 }
1043}
1044
1045/// Read a drop synchronously. See [`Dropped`].
1046pub fn read_drop(ev: &web_sys::DragEvent) -> Dropped {
1047 let Some(dt) = ev.data_transfer() else {
1048 return Dropped {
1049 entries: Vec::new(),
1050 files: Vec::new(),
1051 };
1052 };
1053 let items = dt.items();
1054 let mut entries = Vec::new();
1055 for i in 0..items.length() {
1056 if let Some(item) = items.get(i)
1057 && item.kind() == "file"
1058 && let Ok(Some(entry)) = item.webkit_get_as_entry()
1059 {
1060 entries.push(entry);
1061 }
1062 }
1063 let mut files = Vec::new();
1064 if let Some(list) = dt.files() {
1065 for i in 0..list.length() {
1066 if let Some(f) = list.get(i) {
1067 files.push(f);
1068 }
1069 }
1070 }
1071 Dropped { entries, files }
1072}
1073
1074/// The files of a drop as `(relative path, file)`. Dropped folders are
1075/// walked through the entries API, which is what gives them a path; the
1076/// plain `files` list flattens them to nothing. Browsers without that API
1077/// get the flat list.
1078///
1079/// Each directory's files are resolved in one `Promise.all`: `entry.file()`
1080/// is a round trip into the browser process, and 80 000 of them in a row
1081/// take minutes.
1082pub async fn files_from_drop(dropped: Dropped) -> Vec<(String, web_sys::File)> {
1083 let Dropped { entries, files } = dropped;
1084 let mut out = Vec::new();
1085 if entries.is_empty() {
1086 return files.into_iter().map(|f| (f.name(), f)).collect();
1087 }
1088 // Iterative walk: a stack instead of recursion keeps the future `Sized`.
1089 // Top-level files are one batch; then each directory is one batch.
1090 let mut dirs: Vec<(String, web_sys::FileSystemDirectoryEntry)> = Vec::new();
1091 let mut files: Vec<(String, web_sys::FileSystemFileEntry)> = Vec::new();
1092 for e in entries {
1093 let name = e.name();
1094 if e.is_directory() {
1095 dirs.push((name, e.unchecked_into()));
1096 } else if e.is_file() {
1097 files.push((name, e.unchecked_into()));
1098 }
1099 }
1100 out.extend(resolve_files(files).await);
1101 while let Some((path, dir)) = dirs.pop() {
1102 let reader = dir.create_reader();
1103 let mut files = Vec::new();
1104 // `readEntries` hands out batches (Chrome: 100) until an empty one.
1105 loop {
1106 let batch = read_entries(&reader).await;
1107 if batch.is_empty() {
1108 break;
1109 }
1110 for e in batch {
1111 let sub = format!("{path}/{}", e.name());
1112 if e.is_directory() {
1113 dirs.push((sub, e.unchecked_into()));
1114 } else if e.is_file() {
1115 files.push((sub, e.unchecked_into()));
1116 }
1117 }
1118 }
1119 out.extend(resolve_files(files).await);
1120 }
1121 out
1122}
1123
1124/// `entry.file()` for every entry at once. An entry that fails (vanished
1125/// mid-drop) is left out.
1126async fn resolve_files(
1127 entries: Vec<(String, web_sys::FileSystemFileEntry)>,
1128) -> Vec<(String, web_sys::File)> {
1129 if entries.is_empty() {
1130 return Vec::new();
1131 }
1132 let promises = js_sys::Array::new();
1133 for (_, entry) in &entries {
1134 let p = js_sys::Promise::new(&mut |resolve, _reject| {
1135 let resolve2 = resolve.clone();
1136 let ok = Closure::once_into_js(move |f: web_sys::File| {
1137 let _ = resolve2.call1(&JsValue::NULL, &f);
1138 });
1139 let err = Closure::once_into_js(move |_e: JsValue| {
1140 let _ = resolve.call1(&JsValue::NULL, &JsValue::NULL);
1141 });
1142 entry.file_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
1143 });
1144 promises.push(&p);
1145 }
1146 let all = match wasm_bindgen_futures::JsFuture::from(js_sys::Promise::all(&promises)).await {
1147 Ok(a) => a,
1148 Err(_) => return Vec::new(),
1149 };
1150 entries
1151 .into_iter()
1152 .zip(js_sys::Array::from(&all).iter())
1153 .filter_map(|((path, _), v)| v.dyn_into::<web_sys::File>().ok().map(|f| (path, f)))
1154 .collect()
1155}
1156
1157async fn read_entries(
1158 reader: &web_sys::FileSystemDirectoryReader,
1159) -> Vec<web_sys::FileSystemEntry> {
1160 let p = js_sys::Promise::new(&mut |resolve, reject| {
1161 let ok = Closure::once_into_js(move |arr: JsValue| {
1162 let _ = resolve.call1(&JsValue::NULL, &arr);
1163 });
1164 let err = Closure::once_into_js(move |e: JsValue| {
1165 let _ = reject.call1(&JsValue::NULL, &e);
1166 });
1167 let _ =
1168 reader.read_entries_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
1169 });
1170 match wasm_bindgen_futures::JsFuture::from(p).await {
1171 Ok(arr) => js_sys::Array::from(&arr)
1172 .iter()
1173 // `unchecked_into`: Chromium has no global `FileSystemEntry`,
1174 // so an `instanceof` check (`dyn_into`) fails for every entry.
1175 .map(|v| v.unchecked_into())
1176 .collect(),
1177 Err(_) => Vec::new(),
1178 }
1179}
1180
1181// ---------------------------------------------------------------------------
1182// Calendars and address books
1183// ---------------------------------------------------------------------------
1184
1185fn enc(s: &str) -> String {
1186 js_sys::encode_uri_component(s).into()
1187}
1188
1189pub fn pim_collections()
1190-> impl std::future::Future<Output = Result<Vec<PimCollectionInfo>, ApiError>> {
1191 request("GET", PIM_COLLECTIONS.to_string(), None::<()>)
1192}
1193
1194pub fn pim_create_collection(
1195 body: CreatePimCollection,
1196) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
1197 request("POST", PIM_COLLECTIONS.to_string(), Some(body))
1198}
1199
1200pub fn pim_update_collection(
1201 id: i64,
1202 body: UpdatePimCollection,
1203) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
1204 request("PUT", format!("{PIM_COLLECTIONS}/{id}"), Some(body))
1205}
1206
1207/// Deletes an own collection, or ends the loan of a lent one.
1208pub fn pim_delete_collection(
1209 id: i64,
1210) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1211 request("DELETE", format!("{PIM_COLLECTIONS}/{id}"), None::<()>)
1212}
1213
1214pub fn pim_shares(
1215 id: i64,
1216) -> impl std::future::Future<Output = Result<Vec<PimShareInfo>, ApiError>> {
1217 request(
1218 "GET",
1219 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
1220 None::<()>,
1221 )
1222}
1223
1224/// The accounts an own collection can still be lent to.
1225pub fn pim_share_candidates(
1226 id: i64,
1227) -> impl std::future::Future<Output = Result<Vec<PimShareCandidate>, ApiError>> {
1228 request(
1229 "GET",
1230 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}"),
1231 None::<()>,
1232 )
1233}
1234
1235/// Lends a collection, or changes the mode of a loan.
1236pub fn pim_share(
1237 id: i64,
1238 user: &str,
1239 mode: PimShareMode,
1240) -> impl std::future::Future<Output = Result<PimShareInfo, ApiError>> {
1241 request(
1242 "POST",
1243 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
1244 Some(CreatePimShare {
1245 user: user.to_string(),
1246 mode,
1247 }),
1248 )
1249}
1250
1251pub fn pim_unshare(
1252 id: i64,
1253 user_id: i64,
1254) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1255 request(
1256 "DELETE",
1257 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}"),
1258 None::<()>,
1259 )
1260}
1261
1262pub fn pim_links(id: i64) -> impl std::future::Future<Output = Result<Vec<PimLinkInfo>, ApiError>> {
1263 request(
1264 "GET",
1265 format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
1266 None::<()>,
1267 )
1268}
1269
1270pub fn pim_create_link(
1271 id: i64,
1272 body: CreatePimLink,
1273) -> impl std::future::Future<Output = Result<PimLinkInfo, ApiError>> {
1274 request(
1275 "POST",
1276 format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
1277 Some(body),
1278 )
1279}
1280
1281pub fn pim_delete_link(
1282 id: i64,
1283 link_id: i64,
1284) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1285 request(
1286 "DELETE",
1287 format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}"),
1288 None::<()>,
1289 )
1290}
1291
1292/// Imports an `.ics` or `.vcf` file into a collection.
1293pub async fn pim_import(id: i64, file: web_sys::File) -> Result<PimImportResult, ApiError> {
1294 let opts = web_sys::RequestInit::new();
1295 opts.set_method("POST");
1296 opts.set_mode(web_sys::RequestMode::SameOrigin);
1297 opts.set_body(&file.into());
1298 let url = format!("{PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}");
1299 let resp = fetch_checked(&url, &opts, "import failed").await?;
1300 read_json(&resp).await
1301}
1302
1303/// Uploads a file as a new collection. An empty `name` lets the server take
1304/// the file's own name for itself, or `file_name`.
1305pub async fn pim_import_new(
1306 kind: PimCollectionKind,
1307 name: &str,
1308 color: &str,
1309 file: web_sys::File,
1310) -> Result<PimImportNew, ApiError> {
1311 let opts = web_sys::RequestInit::new();
1312 opts.set_method("POST");
1313 opts.set_mode(web_sys::RequestMode::SameOrigin);
1314 let kind = match kind {
1315 PimCollectionKind::Calendar => "calendar",
1316 PimCollectionKind::Addressbook => "addressbook",
1317 };
1318 let url = format!(
1319 "{PIM_IMPORT_NEW}?kind={kind}&name={}&file={}&color={}",
1320 enc(name),
1321 enc(&file.name()),
1322 enc(color)
1323 );
1324 opts.set_body(&file.into());
1325 let resp = fetch_checked(&url, &opts, "import failed").await?;
1326 read_json(&resp).await
1327}
1328
1329/// Downloads a collection as one `.ics` or `.vcf` file.
1330pub fn pim_export_url(id: i64) -> String {
1331 format!("{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}")
1332}
1333
1334/// The instances of the readable calendars between `from` and `to` (RFC
1335/// 3339), with dates and floating times read in `tz`.
1336pub fn pim_instances(
1337 from: &str,
1338 to: &str,
1339 tz: &str,
1340) -> impl std::future::Future<Output = Result<PimInstances, ApiError>> {
1341 request(
1342 "GET",
1343 format!(
1344 "{PIM_INSTANCES}?{P_FROM}={}&{P_TO}={}&{P_TZ}={}",
1345 enc(from),
1346 enc(to),
1347 enc(tz)
1348 ),
1349 None::<()>,
1350 )
1351}
1352
1353/// One event or contact. `recurrence_id` picks an instance of a series.
1354pub fn pim_object(
1355 id: i64,
1356 name: &str,
1357 recurrence_id: Option<&str>,
1358 tz: &str,
1359) -> impl std::future::Future<Output = Result<PimObjectDetail, ApiError>> {
1360 let mut url = format!(
1361 "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}?{P_TZ}={}",
1362 enc(name),
1363 enc(tz)
1364 );
1365 if let Some(rid) = recurrence_id {
1366 url.push_str(&format!("&{P_RECURRENCE_ID}={}", enc(rid)));
1367 }
1368 request("GET", url, None::<()>)
1369}
1370
1371/// A contact's photo as a small WebP.
1372pub fn pim_photo_url(id: i64, name: &str) -> String {
1373 format!(
1374 "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
1375 enc(name)
1376 )
1377}
1378
1379/// The contacts of the address books `collections`.
1380pub fn pim_contacts(
1381 q: &str,
1382 collections: &[i64],
1383) -> impl std::future::Future<Output = Result<Vec<PimContact>, ApiError>> {
1384 let ids = collections
1385 .iter()
1386 .map(i64::to_string)
1387 .collect::<Vec<_>>()
1388 .join(",");
1389 request(
1390 "GET",
1391 format!(
1392 "{PIM_CONTACTS}?{P_Q}={}&{}={ids}",
1393 enc(q),
1394 api_types::P_COLLECTIONS
1395 ),
1396 None::<()>,
1397 )
1398}
1399
1400pub fn pim_invitations(
1401 tz: &str,
1402) -> impl std::future::Future<Output = Result<Vec<PimInvitation>, ApiError>> {
1403 request(
1404 "GET",
1405 format!("{PIM_INVITATIONS}?{P_TZ}={}", enc(tz)),
1406 None::<()>,
1407 )
1408}
1409
1410/// Answers an invitation as the calendar owner.
1411pub fn pim_reply(body: PimReply) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1412 request("POST", PIM_INVITATIONS.to_string(), Some(body))
1413}
1414
1415pub fn list_rooms() -> impl std::future::Future<Output = Result<Vec<RoomInfo>, ApiError>> {
1416 request("GET", ADMIN_ROOMS.to_string(), None::<()>)
1417}
1418
1419pub fn create_room(
1420 name: &str,
1421 display_name: &str,
1422 kind: RoomKind,
1423) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
1424 request(
1425 "POST",
1426 ADMIN_ROOMS.to_string(),
1427 Some(CreateRoom {
1428 name: name.to_string(),
1429 display_name: Some(display_name.to_string()).filter(|d| !d.is_empty()),
1430 kind,
1431 }),
1432 )
1433}
1434
1435pub fn update_room(
1436 id: i64,
1437 display_name: &str,
1438) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
1439 request(
1440 "PUT",
1441 format!("{ADMIN_ROOMS}/{id}"),
1442 Some(UpdateRoom {
1443 display_name: display_name.to_string(),
1444 }),
1445 )
1446}
1447
1448pub fn delete_room(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1449 request("DELETE", format!("{ADMIN_ROOMS}/{id}"), None::<()>)
1450}
1451
1452/// The signed-in user's own feed links and loans.
1453pub fn pim_own_shares() -> impl std::future::Future<Output = Result<PimOwnShares, ApiError>> {
1454 request("GET", PIM_SHARES.to_string(), None::<()>)
1455}
1456
1457/// Admin only: every public calendar and address book feed.
1458pub fn admin_pim_links() -> impl std::future::Future<Output = Result<Vec<AdminPimLink>, ApiError>> {
1459 request("GET", ADMIN_PIM_LINKS.to_string(), None::<()>)
1460}
1461
1462pub fn admin_delete_pim_link(
1463 id: i64,
1464) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1465 request("DELETE", format!("{ADMIN_PIM_LINKS}/{id}"), None::<()>)
1466}
1467
1468// ---------------------------------------------------------------------------
1469// Low-level request helpers
1470// ---------------------------------------------------------------------------
1471
1472async fn request<T: DeserializeOwned>(
1473 method: &str,
1474 url: String,
1475 body: Option<impl Serialize>,
1476) -> Result<T, ApiError> {
1477 let opts = web_sys::RequestInit::new();
1478 opts.set_method(method);
1479 opts.set_mode(web_sys::RequestMode::SameOrigin);
1480 if let Some(body) = body {
1481 let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
1482 opts.set_body_opt_str(Some(&json));
1483 let headers = web_sys::Headers::new().expect("Headers constructor failed");
1484 let _ = headers.set("Content-Type", "application/json");
1485 opts.set_headers_headers(&headers);
1486 }
1487
1488 let resp = fetch_checked(&url, &opts, "request failed").await?;
1489 read_json(&resp).await
1490}
1491
1492/// Run one fetch and return the response, turning any non-2xx status into
1493/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
1494/// message. Callers that need the raw response (text, a header, or nothing at
1495/// all) use this directly; JSON callers go through [`request`].
1496async fn fetch_checked(
1497 url: &str,
1498 opts: &web_sys::RequestInit,
1499 fallback_msg: &str,
1500) -> Result<web_sys::Response, ApiError> {
1501 let window =
1502 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
1503 let req = web_sys::Request::new_with_str_and_init(url, opts)
1504 .map_err(|e| ApiError::Net(js_msg(&e)))?;
1505
1506 let promise = window.fetch_with_request(&req);
1507 // `fetch` only rejects when no response arrived at all (server down,
1508 // connection lost, blocked): one short localized line instead of the
1509 // JS stack.
1510 let resp_val = JsFuture::from(promise).await.map_err(|_| {
1511 ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string())
1512 })?;
1513 let resp: web_sys::Response = resp_val
1514 .dyn_into()
1515 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
1516
1517 let status = resp.status();
1518 if !(200..300).contains(&status) {
1519 return Err(parse_error_body(&resp)
1520 .await
1521 .into_error(status, fallback_msg));
1522 }
1523 Ok(resp)
1524}
1525
1526/// Read a response body as text and parse it with serde_json.
1527///
1528/// Going through text rather than `Response::json()` keeps one JSON
1529/// implementation in play. It also keeps the server's 64-bit integers exact:
1530/// a detour through a JS value would round file sizes through an f64.
1531async fn read_json<T: DeserializeOwned>(resp: &web_sys::Response) -> Result<T, ApiError> {
1532 let text = response_text(resp)
1533 .await
1534 .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
1535 serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}")))
1536}
1537
1538async fn response_text(resp: &web_sys::Response) -> Option<String> {
1539 JsFuture::from(resp.text().ok()?).await.ok()?.as_string()
1540}
1541
1542/// Parse the server's error JSON (message + optional conflict list).
1543/// An unparseable body yields the default, and the caller's fallback message.
1544async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
1545 response_text(resp)
1546 .await
1547 .and_then(|t| serde_json::from_str(&t).ok())
1548 .unwrap_or_default()
1549}
1550
1551// ---------------------------------------------------------------------------
1552// Search (streamed)
1553// ---------------------------------------------------------------------------
1554
1555/// Start a search and stream its results as they are found.
1556///
1557/// [`web_sys::EventSource`] is the platform's SSE client: it frames the
1558/// stream and parses the events. `on_event` runs once per event on the main
1559/// thread; `.close()` on the returned source stops the search (the server
1560/// notices the dropped connection and unwinds its walk).
1561///
1562/// A stream that ends or dies mid-way simply stops, without a `done` event.
1563/// An `EventSource` cannot read the server's JSON error body, so a rejected
1564/// request reports one generic message.
1565pub fn search_stream(
1566 q: String,
1567 scope: &str,
1568 root: i64,
1569 // `path`: folder inside the root to start in ("" = the whole root).
1570 path: &str,
1571 on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
1572 // A plain closure, not a `Callback`: the caller may run from a render
1573 // closure whose owner is disposed on the next re-render, which would
1574 // dispose a `Callback` created there before the stream fails.
1575 on_error: impl Fn(String) + 'static,
1576) -> Result<web_sys::EventSource, ApiError> {
1577 let url = format!(
1578 "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}",
1579 js_sys::encode_uri_component(&q),
1580 js_sys::encode_uri_component(path),
1581 );
1582 let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?;
1583
1584 // The server always ends a search with `Done`. `error` fires after that
1585 // for the normal end of the stream too (a reconnect pending), so the flag
1586 // tells a finished search from a dropped or refused connection.
1587 let done = std::rc::Rc::new(std::cell::Cell::new(false));
1588 let done2 = done.clone();
1589 let on_msg =
1590 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
1591 let Some(data) = ev.data().as_string() else {
1592 return;
1593 };
1594 if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(&data) {
1595 if matches!(ev, api_types::SearchEvent::Done { .. }) {
1596 done2.set(true);
1597 }
1598 on_event.run(ev);
1599 }
1600 });
1601 src.set_onmessage(Some(on_msg.as_ref().unchecked_ref()));
1602 on_msg.forget();
1603
1604 // A reconnect would re-run the whole search, so close the source either
1605 // way. `CLOSED` means the server answered and rejected the request (401,
1606 // 403, 400); anything else with no `Done` is a lost connection.
1607 let s = src.clone();
1608 let on_err = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1609 let rejected = s.ready_state() == web_sys::EventSource::CLOSED;
1610 s.close();
1611 if done.get() {
1612 return;
1613 }
1614 let key = if rejected {
1615 crate::i18n::k::SEARCH_FAILED
1616 } else {
1617 crate::i18n::k::SERVER_UNREACHABLE
1618 };
1619 on_error(crate::i18n::t(key).to_string());
1620 });
1621 src.set_onerror(Some(on_err.as_ref().unchecked_ref()));
1622 on_err.forget();
1623
1624 Ok(src)
1625}
1626
1627/// Blank an input, so a password does not sit in the DOM waiting for the next
1628/// person at the keyboard.
1629pub fn clear_input(id: &str) {
1630 if let Some(el) = web_sys::window()
1631 .and_then(|w| w.document())
1632 .and_then(|d| d.get_element_by_id(id))
1633 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
1634 {
1635 el.set_value("");
1636 }
1637}
1638
1639/// Read a form input's value by element id.
1640pub fn input_value(id: &str) -> String {
1641 web_sys::window()
1642 .and_then(|w| w.document())
1643 .and_then(|d| {
1644 d.get_element_by_id(id)
1645 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
1646 })
1647 .map(|i| i.value())
1648 .unwrap_or_default()
1649}
1650