brep_app/store.rs
1//! The storage / filesystem seam — the ONE platform exception in the
2//! engine-native UI (see `engine-native-ui-design.md`). Everything else in the
3//! UI is platform-agnostic egui; only persistence differs by platform, so it
4//! goes through this trait with two `#[cfg]`-gated impls:
5//!
6//! * **Desktop** → a small config file under the OS config dir.
7//! * **Browser** → `localStorage` via `web_sys`.
8//!
9//! This slice uses it for settings persistence (`key = "settings"`, `val =` the
10//! full `RenderSettings::to_json()`). Model save/load + import/export will cross
11//! the SAME trait in a later slice.
12
13/// A minimal key→string blob store. Keys are short, filesystem-safe slugs.
14pub trait Store {
15 /// Load a previously-saved value, or `None` if absent/unreadable.
16 fn load(&self, key: &str) -> Option<String>;
17 /// Persist a value under `key`, overwriting any prior value. Best-effort:
18 /// a failure is swallowed (persistence is a convenience, not correctness).
19 fn save(&self, key: &str, val: &str);
20}
21
22/// Construct the platform's default store. The ONLY platform-specific
23/// construction in the app — the trait object it returns is used identically on
24/// web and desktop.
25pub fn default_store() -> Box<dyn Store> {
26 #[cfg(not(target_arch = "wasm32"))]
27 {
28 Box::new(native::FileStore::new())
29 }
30 #[cfg(target_arch = "wasm32")]
31 {
32 Box::new(web::LocalStore)
33 }
34}
35
36// --- Desktop: a config file ----------------------------------------------------
37#[cfg(not(target_arch = "wasm32"))]
38mod native {
39 use super::Store;
40 use std::path::PathBuf;
41
42 /// Persist each key as `<config>/brep-app/<key>.json`. `<config>` is
43 /// `$XDG_CONFIG_HOME`, else `$HOME/.config`, else the current dir — a sane
44 /// default without pulling in a directories crate.
45 pub struct FileStore {
46 dir: PathBuf,
47 }
48
49 impl FileStore {
50 pub fn new() -> Self {
51 let base = std::env::var_os("XDG_CONFIG_HOME")
52 .map(PathBuf::from)
53 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
54 .unwrap_or_else(|| PathBuf::from("."));
55 Self {
56 dir: base.join("brep-app"),
57 }
58 }
59
60 /// Filesystem-safe path for a key (keys are our own short slugs, but be
61 /// defensive against separators).
62 fn path(&self, key: &str) -> PathBuf {
63 let safe: String = key
64 .chars()
65 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
66 .collect();
67 self.dir.join(format!("{safe}.json"))
68 }
69 }
70
71 impl Store for FileStore {
72 fn load(&self, key: &str) -> Option<String> {
73 std::fs::read_to_string(self.path(key)).ok()
74 }
75
76 fn save(&self, key: &str, val: &str) {
77 let _ = std::fs::create_dir_all(&self.dir);
78 let _ = std::fs::write(self.path(key), val);
79 }
80 }
81}
82
83// --- Browser: localStorage -----------------------------------------------------
84#[cfg(target_arch = "wasm32")]
85mod web {
86 use super::Store;
87
88 /// `window.localStorage`-backed store. Keys are namespaced so they can't
89 /// collide with anything else on the origin.
90 pub struct LocalStore;
91
92 impl LocalStore {
93 fn storage() -> Option<web_sys::Storage> {
94 web_sys::window()?.local_storage().ok()?
95 }
96 fn namespaced(key: &str) -> String {
97 format!("brep-app:{key}")
98 }
99 }
100
101 impl Store for LocalStore {
102 fn load(&self, key: &str) -> Option<String> {
103 Self::storage()?.get_item(&Self::namespaced(key)).ok()?
104 }
105
106 fn save(&self, key: &str, val: &str) {
107 if let Some(storage) = Self::storage() {
108 let _ = storage.set_item(&Self::namespaced(key), val);
109 }
110 }
111 }
112}
113
114// =============================================================================
115// Model documents — the OTHER half of the storage seam
116// =============================================================================
117//
118// The `Store` above is a settings key→value blob. A MODEL is a whole document
119// (the engine-owned `HistoryRequest` JSON — one `.BREP.json` recipe), and the
120// file panel needs to *enumerate*, read, and write NAMED documents, plus (in the
121// browser) hand a file to / take a file from the user's real filesystem. That is
122// a different shape than key/value, so it gets its OWN trait, still with the two
123// `#[cfg]`-gated platform impls behind it (the storage seam is the ONE platform
124// exception — see `engine-native-ui-design.md`).
125//
126// The trait is deliberately a **named-document CRUD** (`list` / `read` / `write`
127// / `remove`) plus a poll-based **file-interchange** side-channel. That shape is
128// exactly what a later **GitHub backend** needs: `list` = a repo directory
129// listing, `read` = fetch a file's contents, `write` = create/update (commit)
130// a file, `remove` = delete a file — the model name is the path within the repo.
131// An async backend (GitHub over HTTP, or the browser File System Access API /
132// OPFS whose main-thread API is async) slots in behind the SAME trait by driving
133// its request on a background task and surfacing the result through the
134// `begin_import` → `take_import` poll pattern used here for uploads, so the
135// synchronous panel code never changes. GitHub itself is a deferred follow-up;
136// this lands the seam it plugs into.
137
138/// A store of NAMED model documents — the model-file half of the storage seam
139/// (distinct from the settings key/value [`Store`]). All methods take `&self`
140/// (any per-backend mutable state — the browser's upload stash — lives behind
141/// interior mutability) so the app can hold one `Box<dyn ModelStore>` and hand
142/// panels a shared `&dyn ModelStore`, exactly like [`Store`].
143pub trait ModelStore {
144 /// A short human label of where documents persist, for the panel header
145 /// (e.g. `"filesystem: ~/.config/brep-app/models"` or `"browser storage"`).
146 fn backend_label(&self) -> String;
147
148 /// The names of the documents currently available to **Open** (bare names,
149 /// no extension). May be empty on a backend that cannot enumerate — then the
150 /// panel falls back to the name field / import.
151 fn list(&self) -> Vec<String>;
152
153 /// Read a stored document by name, or `None` if absent/unreadable.
154 fn read(&self, name: &str) -> Option<String>;
155
156 /// Create or overwrite the document `name` with `contents`. `Err` carries a
157 /// message the panel surfaces in its status line.
158 fn write(&self, name: &str, contents: &str) -> Result<(), String>;
159
160 /// Delete the document `name` (best-effort; `Ok` if it is already gone).
161 fn remove(&self, name: &str) -> Result<(), String>;
162
163 // --- real-file interchange (the platform "fallback") ----------------------
164 // The browser cannot silently write to an arbitrary path, and a headless
165 // native box has no dialog; these let the panel move a document to/from the
166 // user's REAL filesystem where the platform supports it. Default: unsupported.
167
168 /// Whether this backend can exchange files with the user's real filesystem
169 /// (browser download+upload, or a native file dialog). The panel shows the
170 /// Download / Import affordances only when this is `true`.
171 fn supports_file_interchange(&self) -> bool {
172 false
173 }
174
175 /// Hand `contents` to the user as a file named after `name` (browser: a
176 /// download; native w/ dialog: a Save-As). Returns the saved document's
177 /// identity — the full path the user chose on native (so the caller can
178 /// re-save straight to it), the bare download name on the web — or `None`
179 /// when the user cancelled. No-op (`None`) by default.
180 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
181 let _ = (name, contents);
182 Ok(None)
183 }
184
185 /// Begin importing a real file — opens the platform picker. The result is
186 /// retrieved later via [`Self::take_import`] (upload/read is async in the
187 /// browser). No-op by default.
188 fn begin_import(&self) -> Result<(), String> {
189 Ok(())
190 }
191
192 /// Poll for a completed import as `(suggested_name, contents)`, consuming it.
193 /// `None` until a `begin_import` finishes. Default: never any.
194 fn take_import(&self) -> Option<(String, String)> {
195 None
196 }
197
198 // --- format-typed interchange (STEP / STL) --------------------------------
199 // The model lanes above trade the `.BREP.json` recipe; import/export of a
200 // foreign format (STEP text, ASCII STL) needs a DIFFERENT picker filter and
201 // must NOT mangle the file extension. These two methods add that lane while
202 // leaving the model lanes byte-for-byte. They share the SAME `take_import`
203 // pickup channel — the panel routes the result by its filename extension.
204
205 /// Begin importing a real file behind a specific picker `filter` (a human
206 /// label + dot-less extensions, e.g. `("STEP", &["step","stp"])`). The chosen
207 /// file's contents + its FULL name (extension preserved, so the panel can
208 /// route it) arrive via [`Self::take_import`]. Default: reuse [`Self::begin_import`].
209 fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
210 self.begin_import()
211 }
212
213 /// Hand `contents` to the user under EXACTLY `file_name` (extension included,
214 /// no model-extension munging) — the Save-As / download for a foreign export
215 /// format. Default no-op (interchange unsupported).
216 fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
217 Ok(())
218 }
219}
220
221/// Construct the platform's default model-document store — the sibling of
222/// [`default_store`] for whole model files.
223pub fn default_model_store() -> Box<dyn ModelStore> {
224 #[cfg(not(target_arch = "wasm32"))]
225 {
226 Box::new(native_model::FileModelStore::new())
227 }
228 #[cfg(target_arch = "wasm32")]
229 {
230 Box::new(web_model::LocalModelStore::new())
231 }
232}
233
234/// The document extension for a model recipe (`<name>.BREP.json`).
235pub const MODEL_EXT: &str = ".BREP.json";
236
237/// Test-only: a native model store rooted at an explicit directory (a temp dir
238/// in tests) so a round-trip test never touches the real config dir.
239#[cfg(all(test, not(target_arch = "wasm32")))]
240pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
241 Box::new(native_model::FileModelStore::with_dir(dir))
242}
243
244/// Strip the model extension (and any directory) from a filename to get the
245/// bare display name — shared by both platform impls (and the file panel, which
246/// shows the bare name while keeping the raw identity for re-saves).
247pub(crate) fn model_display_name(file_name: &str) -> String {
248 let base = file_name
249 .rsplit(['/', '\\'])
250 .next()
251 .unwrap_or(file_name);
252 base.strip_suffix(MODEL_EXT)
253 .or_else(|| base.strip_suffix(".json"))
254 .unwrap_or(base)
255 .to_string()
256}
257
258// --- Desktop: a models directory (+ optional native file dialog) ---------------
259#[cfg(not(target_arch = "wasm32"))]
260mod native_model {
261 use super::{model_display_name, ModelStore, MODEL_EXT};
262 use std::path::PathBuf;
263
264 /// Persist each model as `<config>/brep-app/models/<name>.BREP.json`, using
265 /// the same config-dir resolution as the settings [`super::native::FileStore`]
266 /// so both halves of the seam live together. Enumerable (for the Open list)
267 /// and unit-testable without a display.
268 ///
269 /// With the (default-on) `native-dialog` cargo feature, Open / Save As /
270 /// Import / Export drive a real `rfd` OS file dialog against the real
271 /// filesystem; built with `--no-default-features` (the lean headless gate)
272 /// file interchange is off and the panel uses the name field + the
273 /// models-dir listing instead. Test stores ([`Self::with_dir`]) never open
274 /// dialogs either way, keeping the suite headless.
275 pub struct FileModelStore {
276 dir: PathBuf,
277 /// Whether this store may open OS dialogs: `true` for the real app store
278 /// ([`Self::new`]), `false` for test stores ([`Self::with_dir`]). Only
279 /// read by the `native-dialog` paths — hence the conditional allow.
280 #[cfg_attr(not(feature = "native-dialog"), allow(dead_code))]
281 dialogs: bool,
282 /// A dialog-imported document awaiting pickup (only ever set by the
283 /// `native-dialog` path). `RefCell` keeps the trait `&self`. Unread when
284 /// the feature is off — hence the conditional allow.
285 #[cfg_attr(not(feature = "native-dialog"), allow(dead_code))]
286 pending: std::cell::RefCell<Option<(String, String)>>,
287 }
288
289 impl FileModelStore {
290 pub fn new() -> Self {
291 let base = std::env::var_os("XDG_CONFIG_HOME")
292 .map(PathBuf::from)
293 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
294 .unwrap_or_else(|| PathBuf::from("."));
295 Self {
296 dir: base.join("brep-app").join("models"),
297 dialogs: true,
298 pending: std::cell::RefCell::new(None),
299 }
300 }
301
302 /// A store rooted at an explicit directory — used by the round-trip test.
303 /// Never opens dialogs, so the tests stay headless (and keep exercising
304 /// the egui-modal fallback path).
305 #[cfg_attr(not(test), allow(dead_code))]
306 pub fn with_dir(dir: PathBuf) -> Self {
307 Self {
308 dir,
309 dialogs: false,
310 pending: std::cell::RefCell::new(None),
311 }
312 }
313
314 /// Resolve a name to a path. A name containing a path separator is taken
315 /// as an explicit path (e.g. one an `rfd` dialog returned); a bare name
316 /// maps to `<dir>/<sanitized>.BREP.json`.
317 fn resolve(&self, name: &str) -> PathBuf {
318 if name.contains('/') || name.contains('\\') {
319 return PathBuf::from(name);
320 }
321 let safe: String = name
322 .strip_suffix(MODEL_EXT)
323 .unwrap_or(name)
324 .chars()
325 .map(|c| {
326 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
327 c
328 } else {
329 '_'
330 }
331 })
332 .collect();
333 self.dir.join(format!("{safe}{MODEL_EXT}"))
334 }
335 }
336
337 impl ModelStore for FileModelStore {
338 fn backend_label(&self) -> String {
339 format!("filesystem: {}", self.dir.display())
340 }
341
342 fn list(&self) -> Vec<String> {
343 let mut names: Vec<String> = std::fs::read_dir(&self.dir)
344 .into_iter()
345 .flatten()
346 .flatten()
347 .filter_map(|entry| {
348 let name = entry.file_name().to_string_lossy().into_owned();
349 name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
350 })
351 .collect();
352 names.sort();
353 names
354 }
355
356 fn read(&self, name: &str) -> Option<String> {
357 std::fs::read_to_string(self.resolve(name)).ok()
358 }
359
360 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
361 let path = self.resolve(name);
362 if let Some(parent) = path.parent() {
363 std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
364 }
365 std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
366 }
367
368 fn remove(&self, name: &str) -> Result<(), String> {
369 match std::fs::remove_file(self.resolve(name)) {
370 Ok(()) => Ok(()),
371 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
372 Err(e) => Err(format!("remove: {e}")),
373 }
374 }
375
376 // File interchange is available only when built with a native dialog
377 // (and never on a test store).
378 #[cfg(feature = "native-dialog")]
379 fn supports_file_interchange(&self) -> bool {
380 self.dialogs
381 }
382
383 #[cfg(feature = "native-dialog")]
384 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
385 if !self.dialogs {
386 return Ok(None);
387 }
388 let Some(mut path) = rfd::FileDialog::new()
389 .set_file_name(format!("{}{MODEL_EXT}", model_display_name(name)))
390 .add_filter("BREP model", &["BREP.json", "json"])
391 .save_file()
392 else {
393 return Ok(None); // user cancelled
394 };
395 // An extension-less chosen name would be invisible to Open's filter
396 // and `list()` — normalize it to the model extension.
397 if !path.to_string_lossy().to_ascii_lowercase().ends_with(".json") {
398 path = PathBuf::from(format!("{}{MODEL_EXT}", path.display()));
399 }
400 std::fs::write(&path, contents)
401 .map_err(|e| format!("write {}: {e}", path.display()))?;
402 Ok(Some(path.display().to_string()))
403 }
404
405 #[cfg(feature = "native-dialog")]
406 fn begin_import(&self) -> Result<(), String> {
407 if !self.dialogs {
408 return Ok(());
409 }
410 if let Some(path) = rfd::FileDialog::new()
411 .add_filter("BREP model", &["BREP.json", "json"])
412 .pick_file()
413 {
414 let contents =
415 std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
416 // Stash the FULL path as the document identity so a later plain
417 // Save writes back to the file that was opened (`resolve()`
418 // treats a separator-bearing name as an explicit path).
419 *self.pending.borrow_mut() = Some((path.display().to_string(), contents));
420 }
421 Ok(())
422 }
423
424 #[cfg(feature = "native-dialog")]
425 fn take_import(&self) -> Option<(String, String)> {
426 self.pending.borrow_mut().take()
427 }
428
429 // Format-typed interchange: an rfd picker with the given filter; the FULL
430 // file name (extension kept) is stashed so the panel routes by extension.
431 #[cfg(feature = "native-dialog")]
432 fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
433 if !self.dialogs {
434 return Ok(());
435 }
436 let (label, extensions) = filter;
437 if let Some(path) = rfd::FileDialog::new()
438 .add_filter(label, extensions)
439 .pick_file()
440 {
441 let contents =
442 std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
443 let name = path
444 .file_name()
445 .map(|n| n.to_string_lossy().into_owned())
446 .unwrap_or_else(|| path.to_string_lossy().into_owned());
447 *self.pending.borrow_mut() = Some((name, contents));
448 }
449 Ok(())
450 }
451
452 #[cfg(feature = "native-dialog")]
453 fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
454 if !self.dialogs {
455 return Ok(());
456 }
457 if let Some(path) = rfd::FileDialog::new().set_file_name(file_name).save_file() {
458 std::fs::write(&path, contents)
459 .map_err(|e| format!("write {}: {e}", path.display()))
460 } else {
461 Ok(()) // user cancelled
462 }
463 }
464 }
465}
466
467// --- Browser: localStorage documents + download / upload interchange -----------
468#[cfg(target_arch = "wasm32")]
469mod web_model {
470 use super::{model_display_name, ModelStore, MODEL_EXT};
471 use std::cell::RefCell;
472 use wasm_bindgen::prelude::*;
473 use wasm_bindgen::JsCast;
474
475 // The origin-private persistent store on the web is `localStorage` (keys
476 // `brep-app:model:<name>`): synchronous, enumerable, works on eframe's main
477 // thread AND headless — unlike OPFS (async-only on the main thread) and the
478 // File System Access API (needs a permission gesture that fails headless).
479 // It plays the exact "origin-private document store" role and matches the
480 // settings `Store` precedent; **download + upload** below bridge to the
481 // user's REAL filesystem for portability.
482 const PREFIX: &str = "brep-app:model:";
483
484 thread_local! {
485 /// The single hidden `<input type=file>`, created once and reused.
486 static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
487 /// The most recent completed upload, awaiting the panel's poll.
488 static IMPORTED: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
489 }
490
491 pub struct LocalModelStore;
492
493 impl LocalModelStore {
494 pub fn new() -> Self {
495 Self
496 }
497
498 fn storage() -> Option<web_sys::Storage> {
499 web_sys::window()?.local_storage().ok()?
500 }
501
502 fn key(name: &str) -> String {
503 format!("{PREFIX}{}", model_display_name(name))
504 }
505
506 /// Lazily create the reusable hidden file input, wiring its `change`
507 /// handler (which reads the chosen file and stashes it for `take_import`).
508 /// `accept` is (re)applied every call so the picker's filter matches the
509 /// current lane (the `.BREP.json` model lane vs. a `.step` import lane).
510 fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
511 if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
512 existing.set_accept(accept);
513 return Some(existing);
514 }
515 let document = web_sys::window()?.document()?;
516 let input: web_sys::HtmlInputElement =
517 document.create_element("input").ok()?.dyn_into().ok()?;
518 input.set_type("file");
519 input.set_accept(accept);
520 input.set_hidden(true);
521
522 // On change: read files[0] as text; on the reader's load, stash it.
523 let input_for_cb = input.clone();
524 let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
525 let Some(files) = input_for_cb.files() else { return };
526 let Some(file) = files.get(0) else { return };
527 let name = file.name();
528 let Ok(reader) = web_sys::FileReader::new() else { return };
529 let reader_for_load = reader.clone();
530 let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
531 if let Some(text) = reader_for_load.result().ok().and_then(|v| v.as_string())
532 {
533 IMPORTED
534 .with(|c| *c.borrow_mut() = Some((model_display_name(&name), text)));
535 }
536 }) as Box<dyn FnMut(web_sys::Event)>);
537 reader.set_onload(Some(onload.as_ref().unchecked_ref()));
538 // One small per-import leak (the app runs for the page lifetime).
539 onload.forget();
540 let _ = reader.read_as_text(&file);
541 }) as Box<dyn FnMut(web_sys::Event)>);
542 input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
543 onchange.forget(); // created once — leak is bounded
544
545 if let Some(body) = document.body() {
546 let _ = body.append_child(&input);
547 }
548 IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
549 Some(input)
550 }
551 }
552
553 impl ModelStore for LocalModelStore {
554 fn backend_label(&self) -> String {
555 "browser storage (localStorage) · download/upload for files".into()
556 }
557
558 fn list(&self) -> Vec<String> {
559 let Some(storage) = Self::storage() else {
560 return Vec::new();
561 };
562 let mut names = Vec::new();
563 let len = storage.length().unwrap_or(0);
564 for i in 0..len {
565 if let Ok(Some(key)) = storage.key(i) {
566 if let Some(rest) = key.strip_prefix(PREFIX) {
567 names.push(rest.to_string());
568 }
569 }
570 }
571 names.sort();
572 names
573 }
574
575 fn read(&self, name: &str) -> Option<String> {
576 Self::storage()?.get_item(&Self::key(name)).ok()?
577 }
578
579 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
580 let storage = Self::storage().ok_or("no localStorage")?;
581 storage
582 .set_item(&Self::key(name), contents)
583 .map_err(|_| "localStorage write failed (quota?)".to_string())
584 }
585
586 fn remove(&self, name: &str) -> Result<(), String> {
587 if let Some(storage) = Self::storage() {
588 let _ = storage.remove_item(&Self::key(name));
589 }
590 Ok(())
591 }
592
593 fn supports_file_interchange(&self) -> bool {
594 true
595 }
596
597 /// Offer the document to the user as a `<name>.BREP.json` download via a
598 /// Blob object-URL + a synthetic anchor click. The returned identity is
599 /// the bare name (the browser owns where the download lands).
600 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
601 let document = web_sys::window()
602 .and_then(|w| w.document())
603 .ok_or("no document")?;
604 let parts = js_sys::Array::of1(&JsValue::from_str(contents));
605 let options = web_sys::BlobPropertyBag::new();
606 options.set_type("application/json");
607 let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
608 .map_err(|_| "blob create failed".to_string())?;
609 let url = web_sys::Url::create_object_url_with_blob(&blob)
610 .map_err(|_| "object url failed".to_string())?;
611 let anchor: web_sys::HtmlAnchorElement = document
612 .create_element("a")
613 .map_err(|_| "anchor create failed".to_string())?
614 .dyn_into()
615 .map_err(|_| "anchor cast failed".to_string())?;
616 anchor.set_href(&url);
617 anchor.set_download(&format!("{}{MODEL_EXT}", model_display_name(name)));
618 anchor.click();
619 let _ = web_sys::Url::revoke_object_url(&url);
620 Ok(Some(model_display_name(name)))
621 }
622
623 fn begin_import(&self) -> Result<(), String> {
624 let input = Self::ensure_input(".json,.BREP.json,application/json")
625 .ok_or("file input unavailable")?;
626 // Clear so re-selecting the same file still fires `change`.
627 input.set_value("");
628 input.click();
629 Ok(())
630 }
631
632 fn take_import(&self) -> Option<(String, String)> {
633 IMPORTED.with(|c| c.borrow_mut().take())
634 }
635
636 // Format-typed interchange: the same hidden input, refiltered to the
637 // requested extensions. The onchange handler stashes the file's real name
638 // (its extension survives `model_display_name`, since that only strips
639 // `.json` / `.BREP.json`), so the panel routes STEP imports by extension.
640 fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
641 let accept = filter
642 .1
643 .iter()
644 .map(|ext| format!(".{ext}"))
645 .collect::<Vec<_>>()
646 .join(",");
647 let input = Self::ensure_input(&accept).ok_or("file input unavailable")?;
648 input.set_value("");
649 input.click();
650 Ok(())
651 }
652
653 /// Offer `contents` as a download under EXACTLY `file_name` (extension
654 /// kept) — the foreign-format sibling of [`Self::export_file`].
655 fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
656 let document = web_sys::window()
657 .and_then(|w| w.document())
658 .ok_or("no document")?;
659 let parts = js_sys::Array::of1(&JsValue::from_str(contents));
660 let options = web_sys::BlobPropertyBag::new();
661 options.set_type("application/octet-stream");
662 let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
663 .map_err(|_| "blob create failed".to_string())?;
664 let url = web_sys::Url::create_object_url_with_blob(&blob)
665 .map_err(|_| "object url failed".to_string())?;
666 let anchor: web_sys::HtmlAnchorElement = document
667 .create_element("a")
668 .map_err(|_| "anchor create failed".to_string())?
669 .dyn_into()
670 .map_err(|_| "anchor cast failed".to_string())?;
671 anchor.set_href(&url);
672 anchor.set_download(file_name);
673 anchor.click();
674 let _ = web_sys::Url::revoke_object_url(&url);
675 Ok(())
676 }
677 }
678}
679
680#[cfg(all(test, not(target_arch = "wasm32")))]
681mod tests {
682 use super::native_model::FileModelStore;
683 use super::ModelStore;
684
685 #[test]
686 fn native_model_store_round_trips_named_documents() {
687 let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
688 let _ = std::fs::remove_dir_all(&dir);
689 let store = FileModelStore::with_dir(dir.clone());
690
691 // Empty to start.
692 assert!(store.list().is_empty());
693 assert_eq!(store.read("missing"), None);
694
695 // Write two documents, read them back verbatim, and enumerate by name.
696 let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
697 let doc_b = r#"{"features":[]}"#;
698 store.write("alpha", doc_a).unwrap();
699 store.write("beta", doc_b).unwrap();
700 assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
701 assert_eq!(store.read("beta").as_deref(), Some(doc_b));
702 assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
703
704 // The `.BREP.json` extension is transparent to the name.
705 assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
706
707 // Overwrite + remove.
708 store.write("alpha", doc_b).unwrap();
709 assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
710 store.remove("alpha").unwrap();
711 assert_eq!(store.read("alpha"), None);
712 assert_eq!(store.list(), vec!["beta".to_string()]);
713 store.remove("alpha").unwrap(); // removing an absent doc is Ok
714
715 let _ = std::fs::remove_dir_all(&dir);
716 }
717}