commit beeec5f90967f30716a61ed3ecc1ecb663a60ede
parent c9dc4572cbcd2a88ca5db812f03e53cbae66520c
Author: triesap <triesap@radroots.dev>
Date: Mon, 19 Jan 2026 08:05:21 +0000
app-lib: add file helpers
- add file path parsing helpers
- add json download and file picker helpers
- add file read and json parse helpers
- add unit tests for file helpers
Diffstat:
5 files changed, 219 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1579,9 +1579,11 @@ dependencies = [
"gloo-timers",
"js-sys",
"once_cell",
+ "radroots-app-utils",
"regex",
"serde",
"serde-wasm-bindgen",
+ "serde_json",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
diff --git a/Cargo.toml b/Cargo.toml
@@ -41,6 +41,9 @@ web-sys = { version = "0.3.77", features = [
"File",
"FileList",
"FileReader",
+ "Blob",
+ "HtmlAnchorElement",
+ "HtmlElement",
"HtmlInputElement",
"IdbDatabase",
"IdbFactory",
@@ -73,6 +76,7 @@ web-sys = { version = "0.3.77", features = [
"RequestInit",
"Response",
"ResponseType",
+ "Url",
] }
wasm-bindgen-futures = "0.4"
base64 = "0.22"
diff --git a/crates/app-lib/Cargo.toml b/crates/app-lib/Cargo.toml
@@ -11,17 +11,17 @@ crate-type = ["rlib"]
[dependencies]
serde = { workspace = true }
+serde_json = { workspace = true }
serde-wasm-bindgen = { workspace = true }
js-sys = { workspace = true }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
url = { workspace = true }
+futures = { workspace = true }
+radroots-app-utils = { path = "../utils" }
once_cell = { workspace = true }
regex = { workspace = true }
web-sys = { workspace = true }
-[dev-dependencies]
-futures = { workspace = true }
-
[target.'cfg(target_arch = "wasm32")'.dependencies]
gloo-timers = { workspace = true }
diff --git a/crates/app-lib/src/file.rs b/crates/app-lib/src/file.rs
@@ -0,0 +1,208 @@
+#![forbid(unsafe_code)]
+
+use std::fmt;
+
+use radroots_app_utils::types::{FilePath, FilePathBlob, WebFilePath};
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FileError {
+ WindowUnavailable,
+ DocumentUnavailable,
+ ElementUnavailable,
+ BlobFailure,
+ UrlFailure,
+ SerializeFailure,
+ ReadFailure,
+ ParseFailure,
+ EmptyFile,
+ PickerFailure,
+}
+
+impl fmt::Display for FileError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ FileError::WindowUnavailable => f.write_str("error.app.file.window_unavailable"),
+ FileError::DocumentUnavailable => f.write_str("error.app.file.document_unavailable"),
+ FileError::ElementUnavailable => f.write_str("error.app.file.element_unavailable"),
+ FileError::BlobFailure => f.write_str("error.app.file.blob_failure"),
+ FileError::UrlFailure => f.write_str("error.app.file.url_failure"),
+ FileError::SerializeFailure => f.write_str("error.app.file.serialize_failure"),
+ FileError::ReadFailure => f.write_str("error.app.file.read_failure"),
+ FileError::ParseFailure => f.write_str("error.app.file.parse_failure"),
+ FileError::EmptyFile => f.write_str("error.app.file.empty_file"),
+ FileError::PickerFailure => f.write_str("error.app.file.picker_failure"),
+ }
+ }
+}
+
+impl std::error::Error for FileError {}
+
+pub fn parse_file_path(file_path: &str) -> Option<WebFilePath> {
+ if file_path.starts_with("blob:") {
+ let blob_name = file_path.replace("blob:", "").replace("http://", "");
+ return Some(WebFilePath::Blob(FilePathBlob {
+ blob_path: file_path.to_string(),
+ blob_name,
+ mime_type: None,
+ }));
+ }
+ let file_path_file = file_path.rsplit('/').next().unwrap_or("");
+ let mut parts = file_path_file.split('.');
+ let file_name = parts.next().unwrap_or("");
+ let mime_type = parts.next().unwrap_or("");
+ if file_name.is_empty() || mime_type.is_empty() {
+ return None;
+ }
+ Some(WebFilePath::File(FilePath {
+ file_path: file_path.to_string(),
+ file_name: file_name.to_string(),
+ mime_type: mime_type.to_string(),
+ }))
+}
+
+pub fn download_json<T: Serialize>(data: &T, filename: &str) -> Result<(), FileError> {
+ #[cfg(target_arch = "wasm32")]
+ {
+ use wasm_bindgen::JsCast;
+
+ let json = serde_json::to_string_pretty(data).map_err(|_| FileError::SerializeFailure)?;
+ let array = js_sys::Array::new();
+ array.push(&wasm_bindgen::JsValue::from_str(&json));
+ let blob = web_sys::Blob::new_with_str_sequence(&array).map_err(|_| FileError::BlobFailure)?;
+ let url =
+ web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| FileError::UrlFailure)?;
+ let window = web_sys::window().ok_or(FileError::WindowUnavailable)?;
+ let document = window.document().ok_or(FileError::DocumentUnavailable)?;
+ let anchor = document
+ .create_element("a")
+ .map_err(|_| FileError::ElementUnavailable)?;
+ let anchor: web_sys::HtmlAnchorElement =
+ anchor.dyn_into().map_err(|_| FileError::ElementUnavailable)?;
+ anchor.set_href(&url);
+ anchor.set_download(filename);
+ anchor.click();
+ web_sys::Url::revoke_object_url(&url).map_err(|_| FileError::UrlFailure)?;
+ Ok(())
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let _ = data;
+ let _ = filename;
+ Err(FileError::WindowUnavailable)
+ }
+}
+
+pub async fn select_file() -> Result<Option<web_sys::File>, FileError> {
+ #[cfg(target_arch = "wasm32")]
+ {
+ use std::cell::RefCell;
+ use std::rc::Rc;
+ use wasm_bindgen::JsCast;
+
+ let window = web_sys::window().ok_or(FileError::WindowUnavailable)?;
+ let document = window.document().ok_or(FileError::DocumentUnavailable)?;
+ let input = document
+ .create_element("input")
+ .map_err(|_| FileError::ElementUnavailable)?;
+ let input: web_sys::HtmlInputElement =
+ input.dyn_into().map_err(|_| FileError::ElementUnavailable)?;
+ input.set_type("file");
+ input.set_accept("*/*");
+
+ let (sender, receiver) = futures::channel::oneshot::channel();
+ let closure_holder: Rc<RefCell<Option<wasm_bindgen::closure::Closure<dyn FnMut(_)>>>> =
+ Rc::new(RefCell::new(None));
+ let closure_ref = closure_holder.clone();
+ let input_clone = input.clone();
+ *closure_holder.borrow_mut() = Some(wasm_bindgen::closure::Closure::wrap(Box::new(
+ move |_event: web_sys::Event| {
+ let file = input_clone.files().and_then(|list| list.get(0));
+ let _ = sender.send(file);
+ closure_ref.borrow_mut().take();
+ },
+ ) as Box<dyn FnMut(_)>));
+ if let Some(closure) = closure_holder.borrow().as_ref() {
+ input.set_onchange(Some(closure.as_ref().unchecked_ref()));
+ }
+ input.click();
+ receiver.await.map_err(|_| FileError::PickerFailure)
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ Err(FileError::WindowUnavailable)
+ }
+}
+
+pub async fn get_file_text(file: Option<web_sys::File>) -> Result<Option<String>, FileError> {
+ let Some(file) = file else {
+ return Ok(None);
+ };
+ #[cfg(target_arch = "wasm32")]
+ {
+ let text = wasm_bindgen_futures::JsFuture::from(file.text())
+ .await
+ .map_err(|_| FileError::ReadFailure)?;
+ Ok(text.as_string())
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let _ = file;
+ Err(FileError::WindowUnavailable)
+ }
+}
+
+pub async fn parse_file_json<T: DeserializeOwned>(
+ file: Option<web_sys::File>,
+) -> Result<T, FileError> {
+ let contents = get_file_text(file).await?;
+ let Some(contents) = contents else {
+ return Err(FileError::EmptyFile);
+ };
+ if contents.is_empty() {
+ return Err(FileError::EmptyFile);
+ }
+ serde_json::from_str(&contents).map_err(|_| FileError::ParseFailure)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{get_file_text, parse_file_path, FileError};
+
+ #[test]
+ fn parse_file_path_handles_blob_paths() {
+ let parsed = parse_file_path("blob:http://example").expect("parsed");
+ match parsed {
+ radroots_app_utils::types::WebFilePath::Blob(blob) => {
+ assert_eq!(blob.blob_name, "example");
+ }
+ _ => panic!("expected blob"),
+ }
+ }
+
+ #[test]
+ fn parse_file_path_handles_files() {
+ let parsed = parse_file_path("/path/file.txt").expect("parsed");
+ match parsed {
+ radroots_app_utils::types::WebFilePath::File(file) => {
+ assert_eq!(file.file_name, "file");
+ assert_eq!(file.mime_type, "txt");
+ }
+ _ => panic!("expected file"),
+ }
+ }
+
+ #[test]
+ fn get_file_text_none_returns_none() {
+ let result = futures::executor::block_on(get_file_text(None)).expect("ok");
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn parse_file_json_errors_without_file() {
+ let err = futures::executor::block_on(super::parse_file_json::<serde_json::Value>(None))
+ .expect_err("err");
+ assert_eq!(err, FileError::EmptyFile);
+ }
+}
diff --git a/crates/app-lib/src/lib.rs b/crates/app-lib/src/lib.rs
@@ -2,6 +2,7 @@
pub mod browser;
pub mod dom;
+pub mod file;
pub mod fetch;
pub mod geo;
pub mod locale;
@@ -14,6 +15,7 @@ pub mod theme;
pub use browser::{browser_platform, BrowserPlatformInfo};
pub use dom::{el_id, view_effect, DomError};
+pub use file::{download_json, get_file_text, parse_file_json, parse_file_path, select_file, FileError};
pub use fetch::{fetch_json, FetchJsonError, FetchJsonErrorKind, FetchJsonResult};
pub use geo::{geop_init, geop_is_valid, AppGeolocationPoint};
pub use locale::{get_locale, resolve_locale};