pub trait Store {
fn load(&self, key: &str) -> Option<String>;
fn save(&self, key: &str, val: &str);
}
pub fn default_store() -> Box<dyn Store> {
#[cfg(not(target_arch = "wasm32"))]
{
Box::new(native::FileStore::new())
}
#[cfg(target_arch = "wasm32")]
{
Box::new(web::LocalStore)
}
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::Store;
use std::path::PathBuf;
pub struct FileStore {
dir: PathBuf,
}
impl FileStore {
pub fn new() -> Self {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
Self {
dir: base.join("brep-app"),
}
}
fn path(&self, key: &str) -> PathBuf {
let safe: String = key
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
self.dir.join(format!("{safe}.json"))
}
}
impl Store for FileStore {
fn load(&self, key: &str) -> Option<String> {
std::fs::read_to_string(self.path(key)).ok()
}
fn save(&self, key: &str, val: &str) {
let _ = std::fs::create_dir_all(&self.dir);
let _ = std::fs::write(self.path(key), val);
}
}
}
#[cfg(target_arch = "wasm32")]
mod web {
use super::Store;
pub struct LocalStore;
impl LocalStore {
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
fn namespaced(key: &str) -> String {
format!("brep-app:{key}")
}
}
impl Store for LocalStore {
fn load(&self, key: &str) -> Option<String> {
Self::storage()?.get_item(&Self::namespaced(key)).ok()?
}
fn save(&self, key: &str, val: &str) {
if let Some(storage) = Self::storage() {
let _ = storage.set_item(&Self::namespaced(key), val);
}
}
}
}
pub trait ModelStore {
fn backend_label(&self) -> String;
fn list(&self) -> Vec<String>;
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, contents: &str) -> Result<(), String>;
fn remove(&self, name: &str) -> Result<(), String>;
fn supports_file_interchange(&self) -> bool {
false
}
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
let _ = (name, contents);
Ok(None)
}
fn begin_import(&self) -> Result<(), String> {
Ok(())
}
fn take_import(&self) -> Option<(String, String)> {
None
}
fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
self.begin_import()
}
fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
Ok(())
}
}
pub fn default_model_store() -> Box<dyn ModelStore> {
#[cfg(not(target_arch = "wasm32"))]
{
Box::new(native_model::FileModelStore::new())
}
#[cfg(target_arch = "wasm32")]
{
Box::new(web_model::LocalModelStore::new())
}
}
pub const MODEL_EXT: &str = ".BREP.json";
#[cfg(all(test, not(target_arch = "wasm32")))]
pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
Box::new(native_model::FileModelStore::with_dir(dir))
}
pub(crate) fn model_display_name(file_name: &str) -> String {
let base = file_name
.rsplit(['/', '\\'])
.next()
.unwrap_or(file_name);
base.strip_suffix(MODEL_EXT)
.or_else(|| base.strip_suffix(".json"))
.unwrap_or(base)
.to_string()
}
#[cfg(not(target_arch = "wasm32"))]
mod native_model {
use super::{model_display_name, ModelStore, MODEL_EXT};
use std::path::PathBuf;
pub struct FileModelStore {
dir: PathBuf,
#[cfg_attr(not(feature = "native-dialog"), allow(dead_code))]
dialogs: bool,
#[cfg_attr(not(feature = "native-dialog"), allow(dead_code))]
pending: std::cell::RefCell<Option<(String, String)>>,
}
impl FileModelStore {
pub fn new() -> Self {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
Self {
dir: base.join("brep-app").join("models"),
dialogs: true,
pending: std::cell::RefCell::new(None),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn with_dir(dir: PathBuf) -> Self {
Self {
dir,
dialogs: false,
pending: std::cell::RefCell::new(None),
}
}
fn resolve(&self, name: &str) -> PathBuf {
if name.contains('/') || name.contains('\\') {
return PathBuf::from(name);
}
let safe: String = name
.strip_suffix(MODEL_EXT)
.unwrap_or(name)
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
self.dir.join(format!("{safe}{MODEL_EXT}"))
}
}
impl ModelStore for FileModelStore {
fn backend_label(&self) -> String {
format!("filesystem: {}", self.dir.display())
}
fn list(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
})
.collect();
names.sort();
names
}
fn read(&self, name: &str) -> Option<String> {
std::fs::read_to_string(self.resolve(name)).ok()
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
let path = self.resolve(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
}
std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
}
fn remove(&self, name: &str) -> Result<(), String> {
match std::fs::remove_file(self.resolve(name)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("remove: {e}")),
}
}
#[cfg(feature = "native-dialog")]
fn supports_file_interchange(&self) -> bool {
self.dialogs
}
#[cfg(feature = "native-dialog")]
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
if !self.dialogs {
return Ok(None);
}
let Some(mut path) = rfd::FileDialog::new()
.set_file_name(format!("{}{MODEL_EXT}", model_display_name(name)))
.add_filter("BREP model", &["BREP.json", "json"])
.save_file()
else {
return Ok(None); };
if !path.to_string_lossy().to_ascii_lowercase().ends_with(".json") {
path = PathBuf::from(format!("{}{MODEL_EXT}", path.display()));
}
std::fs::write(&path, contents)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(Some(path.display().to_string()))
}
#[cfg(feature = "native-dialog")]
fn begin_import(&self) -> Result<(), String> {
if !self.dialogs {
return Ok(());
}
if let Some(path) = rfd::FileDialog::new()
.add_filter("BREP model", &["BREP.json", "json"])
.pick_file()
{
let contents =
std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
*self.pending.borrow_mut() = Some((path.display().to_string(), contents));
}
Ok(())
}
#[cfg(feature = "native-dialog")]
fn take_import(&self) -> Option<(String, String)> {
self.pending.borrow_mut().take()
}
#[cfg(feature = "native-dialog")]
fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
if !self.dialogs {
return Ok(());
}
let (label, extensions) = filter;
if let Some(path) = rfd::FileDialog::new()
.add_filter(label, extensions)
.pick_file()
{
let contents =
std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
*self.pending.borrow_mut() = Some((name, contents));
}
Ok(())
}
#[cfg(feature = "native-dialog")]
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
if !self.dialogs {
return Ok(());
}
if let Some(path) = rfd::FileDialog::new().set_file_name(file_name).save_file() {
std::fs::write(&path, contents)
.map_err(|e| format!("write {}: {e}", path.display()))
} else {
Ok(()) }
}
}
}
#[cfg(target_arch = "wasm32")]
mod web_model {
use super::{model_display_name, ModelStore, MODEL_EXT};
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
const PREFIX: &str = "brep-app:model:";
thread_local! {
static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
static IMPORTED: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
}
pub struct LocalModelStore;
impl LocalModelStore {
pub fn new() -> Self {
Self
}
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
fn key(name: &str) -> String {
format!("{PREFIX}{}", model_display_name(name))
}
fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
existing.set_accept(accept);
return Some(existing);
}
let document = web_sys::window()?.document()?;
let input: web_sys::HtmlInputElement =
document.create_element("input").ok()?.dyn_into().ok()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
let input_for_cb = input.clone();
let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
let Some(files) = input_for_cb.files() else { return };
let Some(file) = files.get(0) else { return };
let name = file.name();
let Ok(reader) = web_sys::FileReader::new() else { return };
let reader_for_load = reader.clone();
let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
if let Some(text) = reader_for_load.result().ok().and_then(|v| v.as_string())
{
IMPORTED
.with(|c| *c.borrow_mut() = Some((model_display_name(&name), text)));
}
}) as Box<dyn FnMut(web_sys::Event)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
let _ = reader.read_as_text(&file);
}) as Box<dyn FnMut(web_sys::Event)>);
input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
onchange.forget();
if let Some(body) = document.body() {
let _ = body.append_child(&input);
}
IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
Some(input)
}
}
impl ModelStore for LocalModelStore {
fn backend_label(&self) -> String {
"browser storage (localStorage) · download/upload for files".into()
}
fn list(&self) -> Vec<String> {
let Some(storage) = Self::storage() else {
return Vec::new();
};
let mut names = Vec::new();
let len = storage.length().unwrap_or(0);
for i in 0..len {
if let Ok(Some(key)) = storage.key(i) {
if let Some(rest) = key.strip_prefix(PREFIX) {
names.push(rest.to_string());
}
}
}
names.sort();
names
}
fn read(&self, name: &str) -> Option<String> {
Self::storage()?.get_item(&Self::key(name)).ok()?
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
let storage = Self::storage().ok_or("no localStorage")?;
storage
.set_item(&Self::key(name), contents)
.map_err(|_| "localStorage write failed (quota?)".to_string())
}
fn remove(&self, name: &str) -> Result<(), String> {
if let Some(storage) = Self::storage() {
let _ = storage.remove_item(&Self::key(name));
}
Ok(())
}
fn supports_file_interchange(&self) -> bool {
true
}
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or("no document")?;
let parts = js_sys::Array::of1(&JsValue::from_str(contents));
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/json");
let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
.map_err(|_| "blob create failed".to_string())?;
let url = web_sys::Url::create_object_url_with_blob(&blob)
.map_err(|_| "object url failed".to_string())?;
let anchor: web_sys::HtmlAnchorElement = document
.create_element("a")
.map_err(|_| "anchor create failed".to_string())?
.dyn_into()
.map_err(|_| "anchor cast failed".to_string())?;
anchor.set_href(&url);
anchor.set_download(&format!("{}{MODEL_EXT}", model_display_name(name)));
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(Some(model_display_name(name)))
}
fn begin_import(&self) -> Result<(), String> {
let input = Self::ensure_input(".json,.BREP.json,application/json")
.ok_or("file input unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn take_import(&self) -> Option<(String, String)> {
IMPORTED.with(|c| c.borrow_mut().take())
}
fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
let accept = filter
.1
.iter()
.map(|ext| format!(".{ext}"))
.collect::<Vec<_>>()
.join(",");
let input = Self::ensure_input(&accept).ok_or("file input unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or("no document")?;
let parts = js_sys::Array::of1(&JsValue::from_str(contents));
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
.map_err(|_| "blob create failed".to_string())?;
let url = web_sys::Url::create_object_url_with_blob(&blob)
.map_err(|_| "object url failed".to_string())?;
let anchor: web_sys::HtmlAnchorElement = document
.create_element("a")
.map_err(|_| "anchor create failed".to_string())?
.dyn_into()
.map_err(|_| "anchor cast failed".to_string())?;
anchor.set_href(&url);
anchor.set_download(file_name);
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(())
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::native_model::FileModelStore;
use super::ModelStore;
#[test]
fn native_model_store_round_trips_named_documents() {
let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let store = FileModelStore::with_dir(dir.clone());
assert!(store.list().is_empty());
assert_eq!(store.read("missing"), None);
let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
let doc_b = r#"{"features":[]}"#;
store.write("alpha", doc_a).unwrap();
store.write("beta", doc_b).unwrap();
assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
assert_eq!(store.read("beta").as_deref(), Some(doc_b));
assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
store.write("alpha", doc_b).unwrap();
assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
store.remove("alpha").unwrap();
assert_eq!(store.read("alpha"), None);
assert_eq!(store.list(), vec!["beta".to_string()]);
store.remove("alpha").unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}