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