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