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