api.rs
⎇
Raw
1//! Typed HTTP client for the filebrowser-ng 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_MKDIR, ACTION_PREVIEW,
17 ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare,
18 CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_Q, P_ROOT,
19 P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARES, Settings, UpdateUser,
20};
21pub use api_types::{
22 AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
23};
24
25#[derive(Debug, thiserror::Error)]
26pub enum ApiError {
27 /// Non-2xx response. `skipped` carries the server's conflict file list
28 /// when present (upload conflicts).
29 #[error("{message}")]
30 Http {
31 #[allow(dead_code)]
32 status: u16,
33 message: String,
34 skipped: Option<Vec<String>>,
35 },
36 /// The file changed on disk since it was read (save conflict, HTTP 409).
37 #[error("the file was changed on disk")]
38 Conflict,
39 #[error("network error: {0}")]
40 Net(String),
41}
42
43impl ApiError {
44 pub fn skipped(&self) -> Option<&[String]> {
45 match self {
46 ApiError::Http {
47 skipped: Some(s), ..
48 } => Some(s),
49 _ => None,
50 }
51 }
52
53 /// The HTTP status code, when this was an HTTP (non-2xx) error.
54 pub fn status(&self) -> Option<u16> {
55 match self {
56 ApiError::Http { status, .. } => Some(*status),
57 _ => None,
58 }
59 }
60}
61
62/// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`.
63/// The message is already localized in [`fetch_checked`]; `code` carries the
64/// machine-readable identifier the server sends for known failures.
65#[derive(serde::Deserialize, Default)]
66struct ErrBody {
67 #[serde(default)]
68 error: Option<String>,
69 #[serde(default)]
70 code: Option<String>,
71 #[serde(default)]
72 skipped: Option<Vec<String>>,
73}
74
75// ---------------------------------------------------------------------------
76// Auth
77// ---------------------------------------------------------------------------
78
79pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
80 request("GET", AUTH_ME.to_string(), None::<()>)
81}
82
83/// PUT /api/auth/me — update the signed-in user's profile settings.
84/// Omitted fields are left unchanged; `language: Some(None)` means "follow
85/// the browser".
86#[derive(Serialize, Default)]
87struct ProfilePatch {
88 #[serde(skip_serializing_if = "Option::is_none")]
89 single_click_open: Option<bool>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 language: Option<Option<String>>,
92}
93
94pub fn update_profile(
95 single_click_open: Option<bool>,
96 language: Option<Option<String>>,
97) -> impl std::future::Future<Output = Result<Me, ApiError>> {
98 request(
99 "PUT",
100 AUTH_ME.to_string(),
101 Some(ProfilePatch {
102 single_click_open,
103 language,
104 }),
105 )
106}
107
108pub fn login(
109 name: String,
110 password: String,
111) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
112 request(
113 "POST",
114 AUTH_LOGIN.to_string(),
115 Some(Credentials { name, password }),
116 )
117}
118
119pub fn setup(
120 name: String,
121 password: String,
122) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
123 request(
124 "POST",
125 AUTH_SETUP.to_string(),
126 Some(Credentials { name, password }),
127 )
128}
129
130pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
131 request("POST", AUTH_LOGOUT.to_string(), Some(()))
132}
133
134// ---------------------------------------------------------------------------
135// Files
136// ---------------------------------------------------------------------------
137
138/// The public share token while the app is showing a share page. Every file
139/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
140/// shown per page, so a plain static suffices (wasm is single-threaded).
141use std::sync::Mutex;
142static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
143
144/// Set (or clear, with `None`) the share token used by file API calls.
145pub fn set_share_token(token: Option<&str>) {
146 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
147}
148
149fn share_suffix() -> String {
150 SHARE_TOKEN
151 .lock()
152 .unwrap()
153 .as_deref()
154 .map(|t| format!("?{P_SHARE}={t}"))
155 .unwrap_or_default()
156}
157
158/// Append `key=value` to a URL, using `&` when a query string already exists.
159fn append_query(url: &str, kv: &str) -> String {
160 if url.contains('?') {
161 format!("{url}&{kv}")
162 } else {
163 format!("{url}?{kv}")
164 }
165}
166
167fn files_url(root_id: i64, path: &str) -> String {
168 let base = if path.is_empty() {
169 format!("{FILES}/{root_id}")
170 } else {
171 let encoded: Vec<String> = path
172 .split('/')
173 .map(|s| js_sys::encode_uri_component(s).into())
174 .collect();
175 format!("{FILES}/{root_id}/{}", encoded.join("/"))
176 };
177 format!("{base}{}", share_suffix())
178}
179
180pub fn list_files(
181 root_id: i64,
182 path: &str,
183) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
184 request("GET", files_url(root_id, path), None::<()>)
185}
186
187/// Create an empty file. `path` is relative to the root (may contain
188/// subfolders); the file must not exist yet.
189pub fn create_file(
190 root_id: i64,
191 path: &str,
192) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
193 let url = append_query(
194 &files_url(root_id, path),
195 &format!("{P_ACTION}={ACTION_CREATE_FILE}"),
196 );
197 request("POST", url, None::<()>)
198}
199
200/// Create a folder. `path` is relative to the root (may contain subfolders).
201pub fn mkdir(
202 root_id: i64,
203 path: &str,
204) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
205 let url = append_query(
206 &files_url(root_id, path),
207 &format!("{P_ACTION}={ACTION_MKDIR}"),
208 );
209 request("POST", url, None::<()>)
210}
211
212pub fn rename_item(
213 root_id: i64,
214 path: &str,
215 new_name: String,
216 overwrite: bool,
217) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
218 request(
219 "POST",
220 files_url(root_id, path),
221 Some(Mutation {
222 op: Op::Rename,
223 new_name: Some(new_name),
224 dst_root_id: None,
225 dst: None,
226 overwrite,
227 }),
228 )
229}
230
231pub fn move_item(
232 root_id: i64,
233 path: &str,
234 dst_root_id: i64,
235 dst: &str,
236 overwrite: bool,
237) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
238 mutation(root_id, path, Op::Move, dst_root_id, dst, overwrite)
239}
240
241pub fn copy_item(
242 root_id: i64,
243 path: &str,
244 dst_root_id: i64,
245 dst: &str,
246 overwrite: bool,
247) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
248 mutation(root_id, path, Op::Copy, dst_root_id, dst, overwrite)
249}
250
251fn mutation(
252 root_id: i64,
253 path: &str,
254 op: Op,
255 dst_root_id: i64,
256 dst: &str,
257 overwrite: bool,
258) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
259 request(
260 "POST",
261 files_url(root_id, path),
262 Some(Mutation {
263 op,
264 new_name: None,
265 dst_root_id: Some(dst_root_id),
266 dst: Some(dst.to_string()),
267 overwrite,
268 }),
269 )
270}
271
272pub fn delete_item(
273 root_id: i64,
274 path: &str,
275) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
276 request("DELETE", files_url(root_id, path), None::<()>)
277}
278
279// ---------------------------------------------------------------------------
280// Download / preview / content (milestone 4)
281// ---------------------------------------------------------------------------
282
283/// `...?action=download` — a single file as-is, or a folder as `format`.
284pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
285 let mut base = append_query(
286 &files_url(root_id, path),
287 &format!("{P_ACTION}={ACTION_DOWNLOAD}"),
288 );
289 if let Some(f) = format {
290 base = append_query(&base, &format!("{P_FORMAT}={f}"));
291 }
292 base
293}
294
295/// `...?action=preview` — a single file, inline (native media).
296pub fn preview_url(root_id: i64, path: &str) -> String {
297 append_query(
298 &files_url(root_id, path),
299 &format!("{P_ACTION}={ACTION_PREVIEW}"),
300 )
301}
302
303/// `...?action=content` — raw file bytes for the text preview/editor.
304pub fn content_url(root_id: i64, path: &str) -> String {
305 append_query(
306 &files_url(root_id, path),
307 &format!("{P_ACTION}={ACTION_CONTENT}"),
308 )
309}
310
311/// Fetch a file's raw text content plus its mtime (unix seconds), for the
312/// preview and the editor. The mtime anchors the save-time conflict check.
313pub async fn fetch_content_meta(
314 root_id: i64,
315 path: &str,
316) -> Result<(String, Option<i64>), ApiError> {
317 let opts = web_sys::RequestInit::new();
318 opts.set_method("GET");
319 opts.set_mode(web_sys::RequestMode::SameOrigin);
320 let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
321 let mtime: Option<i64> = resp
322 .headers()
323 .get("x-file-mtime")
324 .ok()
325 .flatten()
326 .and_then(|s| s.parse::<i64>().ok());
327 let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
328 let js = JsFuture::from(tp)
329 .await
330 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
331 let text = js
332 .as_string()
333 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
334 Ok((text, mtime))
335}
336
337/// Save a file's text content (the editor's write path).
338///
339/// When `force` is false, `expected_mtime` is sent and the server rejects the
340/// save with [`ApiError::Conflict`] if the file changed on disk since it was
341/// read. When `force` is true the check is skipped (overwrite). Returns the
342/// file's new mtime (unix seconds) to anchor the next check.
343pub async fn save_content(
344 root_id: i64,
345 path: &str,
346 text: &str,
347 expected_mtime: Option<i64>,
348 force: bool,
349) -> Result<i64, ApiError> {
350 let url = content_url(root_id, path);
351 let opts = web_sys::RequestInit::new();
352 opts.set_method("PUT");
353 opts.set_mode(web_sys::RequestMode::SameOrigin);
354 opts.set_body_opt_str(Some(text));
355 let headers = web_sys::Headers::new()
356 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
357 headers
358 .set("Content-Type", "text/plain; charset=utf-8")
359 .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
360 if !force && let Some(m) = expected_mtime {
361 headers
362 .set("X-Expected-Mtime", &m.to_string())
363 .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
364 }
365 opts.set_headers_headers(&headers);
366 // 409 is the server's "changed on disk" answer, not a generic HTTP error.
367 let resp = match fetch_checked(&url, &opts, "could not save file").await {
368 Ok(resp) => resp,
369 Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
370 Err(e) => return Err(e),
371 };
372 let save: SaveResp = read_json(&resp).await?;
373 Ok(save.mtime)
374}
375
376/// Open a URL in a new tab.
377///
378/// `noopener` severs the `window.opener` link, so the opened page cannot
379/// script this one. That matters here because the target is a *user file*:
380/// HTML and SVG render as real documents (under the server's sandbox CSP, see
381/// `FILE_CSP`), and this is the browser-side half of the same isolation.
382pub fn open_in_new_tab(url: &str) {
383 if let Some(w) = web_sys::window() {
384 let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener");
385 }
386}
387
388/// Trigger a browser download of a same-origin URL via a temporary anchor.
389/// No data is pulled into JS memory — the browser streams it.
390pub fn trigger_download(url: &str, filename: &str) {
391 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
392 return;
393 };
394 let Ok(el) = doc.create_element("a") else {
395 return;
396 };
397 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
398 return;
399 };
400 a.set_href(url);
401 a.set_download(filename);
402 if let Some(body) = doc.body() {
403 let _ = body.append_child(&a);
404 }
405 a.click();
406 a.remove();
407}
408
409/// Upload files into a directory. Each part is `(relative_path, file)`;
410/// the relative path may contain subfolders (created on the server).
411///
412/// The multipart body is assembled by hand into a `Blob` (instead of using
413/// `FormData` directly as the fetch body): a FormData body makes Chrome send
414/// the request as a *streaming* body, which forces the HTTP/2-cleartext
415/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
416/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
417/// it goes out as a regular length-prefixed HTTP/1.1 request.
418pub async fn upload(
419 root_id: i64,
420 dir: &str,
421 overwrite: bool,
422 parts: Vec<(String, web_sys::File)>,
423) -> Result<(), ApiError> {
424 let boundary = format!("----fbng{}", random_boundary_suffix());
425 let segments = js_sys::Array::new();
426 for (name, file) in &parts {
427 let basename = name.rsplit('/').next().unwrap_or(name);
428 segments.push(&JsValue::from_str(&format!(
429 "--{boundary}\r\n\
430 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
431 Content-Type: application/octet-stream\r\n\r\n"
432 )));
433 let f: JsValue = file.clone().unchecked_into();
434 segments.push(&f);
435 segments.push(&JsValue::from_str("\r\n"));
436 }
437 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
438 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
439 .map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
440
441 let url = append_query(
442 &files_url(root_id, dir),
443 &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
444 );
445
446 let headers = web_sys::Headers::new()
447 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
448 headers
449 .set(
450 "Content-Type",
451 &format!("multipart/form-data; boundary={boundary}"),
452 )
453 .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
454
455 let opts = web_sys::RequestInit::new();
456 opts.set_method("POST");
457 opts.set_mode(web_sys::RequestMode::SameOrigin);
458 opts.set_headers_headers(&headers);
459 opts.set_body_opt_blob(Some(&body));
460
461 // The error carries the server's `skipped` list on an upload conflict.
462 fetch_checked(&url, &opts, "upload failed").await?;
463 Ok(())
464}
465
466// ---------------------------------------------------------------------------
467// Shares (milestone 6)
468// ---------------------------------------------------------------------------
469
470pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
471 request("GET", SHARES.to_string(), None::<()>)
472}
473
474pub fn create_share(
475 root_id: i64,
476 path: &str,
477 writable: bool,
478 expires_at: Option<&str>,
479) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
480 request(
481 "POST",
482 SHARES.to_string(),
483 Some(CreateShare {
484 root_id,
485 path: path.to_string(),
486 writable,
487 expires_at: expires_at.map(|s| s.to_string()),
488 }),
489 )
490}
491
492pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
493 request("DELETE", format!("{SHARES}/{id}"), None::<()>)
494}
495
496/// Public: resolve a share (no session required).
497pub fn resolve_share(
498 token: &str,
499) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
500 request("GET", format!("{SHARE}/{token}"), None::<()>)
501}
502
503// ---------------------------------------------------------------------------
504// Admin (milestone 7): user management + settings
505// ---------------------------------------------------------------------------
506
507fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
508 roots
509 .iter()
510 .map(|(path, mode)| Root {
511 path: path.clone(),
512 mode: *mode,
513 })
514 .collect()
515}
516
517pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
518 request("GET", ADMIN_USERS.to_string(), None::<()>)
519}
520
521pub fn create_admin_user(
522 name: &str,
523 password: &str,
524 is_admin: bool,
525 roots: &[(String, Mode)],
526) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
527 request(
528 "POST",
529 ADMIN_USERS.to_string(),
530 Some(CreateUser {
531 name: name.to_string(),
532 password: password.to_string(),
533 is_admin,
534 roots: roots_to_bodies(roots),
535 }),
536 )
537}
538
539pub fn update_admin_user(
540 id: i64,
541 password: Option<String>,
542 is_admin: Option<bool>,
543 active: Option<bool>,
544 roots: Option<Vec<(String, Mode)>>,
545) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
546 request(
547 "PUT",
548 format!("{ADMIN_USERS}/{id}"),
549 Some(UpdateUser {
550 password,
551 is_admin,
552 active,
553 roots: roots.map(|r| roots_to_bodies(&r)),
554 }),
555 )
556}
557
558pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
559 request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
560}
561
562pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
563 request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
564}
565
566pub fn update_admin_settings(
567 allow_writable_shares: bool,
568) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
569 request(
570 "PUT",
571 ADMIN_SETTINGS.to_string(),
572 Some(Settings {
573 allow_writable_shares,
574 }),
575 )
576}
577
578// ---------------------------------------------------------------------------
579// File picker (imperative, one at a time)
580// ---------------------------------------------------------------------------
581
582fn random_boundary_suffix() -> String {
583 let mut s = String::with_capacity(16);
584 for _ in 0..16 {
585 let n = (js_sys::Math::random() * 36.0) as u32;
586 s.push(char::from_digit(n, 36).unwrap_or('a'));
587 }
588 s
589}
590
591/// Open the native file dialog and run `on_files` with the picked files once
592/// the user confirms. `directory` uses webkitdirectory (folder upload).
593fn webkit_relative_path(file: &web_sys::File) -> String {
594 let js: JsValue = file.into();
595 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
596 .ok()
597 .and_then(|v| v.as_string())
598 .filter(|s| !s.is_empty())
599 .unwrap_or_default()
600}
601
602pub fn pick_files(
603 multiple: bool,
604 directory: bool,
605 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
606) {
607 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
608 return;
609 };
610 let Ok(el) = doc.create_element("input") else {
611 return;
612 };
613 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
614 return;
615 };
616 input.set_type("file");
617 if multiple {
618 input.set_multiple(true);
619 }
620 if directory {
621 let _ = input.set_attribute("webkitdirectory", "");
622 }
623
624 // Known small leak, deliberate: the input holds the listener, the
625 // listener holds this closure, and the closure captures the input — a
626 // cycle that nothing frees. `forget()` detaches the Rust side, so the
627 // element + JS function + closure (~1 KB) survive until reload even
628 // when the dialog is used (not only when cancelled). Dropping the
629 // closure from Rust while the JS listener still references it would
630 // leave a dangling callback, and a closure cannot drop itself; a clean
631 // fix needs the caller to own it (e.g. a `StoredValue` released in
632 // `on_cleanup`), which is not worth it at this size.
633 let input2 = input.clone();
634 let closure = Closure::<dyn FnMut()>::new(move || {
635 let mut files: Vec<(String, web_sys::File)> = Vec::new();
636 if let Some(list) = input.files() {
637 for i in 0..list.length() {
638 if let Some(f) = list.get(i) {
639 let rel = webkit_relative_path(&f);
640 let name = if rel.is_empty() { f.name() } else { rel };
641 files.push((name, f));
642 }
643 }
644 }
645 input.remove();
646 if !files.is_empty() {
647 on_files(files);
648 }
649 });
650 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
651 let _ = input2.add_event_listener_with_callback("change", listener);
652 closure.forget();
653 if let Some(body) = doc.body() {
654 let _ = body.append_child(&input2);
655 }
656 input2.click();
657}
658
659// ---------------------------------------------------------------------------
660// Low-level request helpers
661// ---------------------------------------------------------------------------
662
663async fn request<T: DeserializeOwned>(
664 method: &str,
665 url: String,
666 body: Option<impl Serialize>,
667) -> Result<T, ApiError> {
668 let opts = web_sys::RequestInit::new();
669 opts.set_method(method);
670 opts.set_mode(web_sys::RequestMode::SameOrigin);
671 if let Some(body) = body {
672 let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
673 opts.set_body_opt_str(Some(&json));
674 let headers = web_sys::Headers::new().expect("Headers constructor failed");
675 let _ = headers.set("Content-Type", "application/json");
676 opts.set_headers_headers(&headers);
677 }
678
679 do_fetch(&url, &opts).await
680}
681
682/// Run one fetch and return the response, turning any non-2xx status into
683/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
684/// message. Callers that need the raw response (text, a header, or nothing at
685/// all) use this directly; JSON callers go through [`do_fetch`].
686async fn fetch_checked(
687 url: &str,
688 opts: &web_sys::RequestInit,
689 fallback_msg: &str,
690) -> Result<web_sys::Response, ApiError> {
691 let window =
692 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
693 let req = web_sys::Request::new_with_str_and_init(url, opts)
694 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
695
696 let promise = window.fetch_with_request(&req);
697 let resp_val = JsFuture::from(promise)
698 .await
699 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
700 let resp: web_sys::Response = resp_val
701 .dyn_into()
702 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
703
704 let status = resp.status();
705 if !(200..300).contains(&status) {
706 let body = parse_error_body(&resp).await;
707 let fallback = body
708 .error
709 .clone()
710 .unwrap_or_else(|| fallback_msg.to_string());
711 return Err(ApiError::Http {
712 status,
713 // Known server errors carry a code the client maps to a
714 // localized message; unknown ones fall back to the raw text.
715 message: crate::i18n::error_text(body.code.as_deref(), &fallback),
716 skipped: body.skipped,
717 });
718 }
719 Ok(resp)
720}
721
722async fn do_fetch<T: DeserializeOwned>(
723 url: &str,
724 opts: &web_sys::RequestInit,
725) -> Result<T, ApiError> {
726 let resp = fetch_checked(url, opts, "request failed").await?;
727 read_json(&resp).await
728}
729
730/// Read a response body as text and parse it with serde_json.
731///
732/// Going through text rather than `Response::json()` keeps one JSON
733/// implementation in play. It also keeps the server's 64-bit integers exact:
734/// a detour through a JS value would round file sizes through an f64.
735async fn read_json<T: DeserializeOwned>(resp: &web_sys::Response) -> Result<T, ApiError> {
736 let text = response_text(resp)
737 .await
738 .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
739 serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}")))
740}
741
742async fn response_text(resp: &web_sys::Response) -> Option<String> {
743 JsFuture::from(resp.text().ok()?).await.ok()?.as_string()
744}
745
746/// Parse the server's error JSON (message + optional conflict list).
747/// An unparseable body yields the default, and the caller's fallback message.
748async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
749 response_text(resp)
750 .await
751 .and_then(|t| serde_json::from_str(&t).ok())
752 .unwrap_or_default()
753}
754
755// ---------------------------------------------------------------------------
756// Search (streamed)
757// ---------------------------------------------------------------------------
758
759/// Start a search and stream its results as they are found.
760///
761/// [`web_sys::EventSource`] is the platform's SSE client: it frames the
762/// stream and parses the events. `on_event` runs once per event on the main
763/// thread; `.close()` on the returned source stops the search (the server
764/// notices the dropped connection and unwinds its walk).
765///
766/// A stream that ends or dies mid-way simply stops, without a `done` event.
767/// An `EventSource` cannot read the server's JSON error body, so a rejected
768/// request reports one generic message.
769pub fn search_stream(
770 q: String,
771 scope: &str,
772 root: i64,
773 on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
774 on_error: leptos::prelude::Callback<String, ()>,
775) -> Result<web_sys::EventSource, ApiError> {
776 let url = format!(
777 "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}",
778 js_sys::encode_uri_component(&q),
779 );
780 let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(format!("{e:?}")))?;
781
782 let on_msg =
783 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
784 let Some(data) = ev.data().as_string() else {
785 return;
786 };
787 if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(&data) {
788 on_event.run(ev);
789 }
790 });
791 src.set_onmessage(Some(on_msg.as_ref().unchecked_ref()));
792 on_msg.forget();
793
794 // `error` fires both when the request is rejected (`CLOSED`) and when the
795 // stream ends normally (`CONNECTING`, a reconnect pending). A reconnect
796 // would re-run the whole search, so close the source either way.
797 let s = src.clone();
798 let on_err = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
799 let failed = s.ready_state() == web_sys::EventSource::CLOSED;
800 s.close();
801 if failed {
802 on_error.run(crate::i18n::error_text(None, "search failed"));
803 }
804 });
805 src.set_onerror(Some(on_err.as_ref().unchecked_ref()));
806 on_err.forget();
807
808 Ok(src)
809}
810
811/// Read a form input's value by element id.
812pub fn input_value(id: &str) -> String {
813 web_sys::window()
814 .and_then(|w| w.document())
815 .and_then(|d| {
816 d.get_element_by_id(id)
817 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
818 })
819 .map(|i| i.value())
820 .unwrap_or_default()
821}
822