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