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