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