Skip to main content

brep_app/
document.rs

1//! The MULTI-DOCUMENT model — the app holds N open models, exactly ONE of which
2//! is active.
3//!
4//! Before this, the shell owned a single `EngineState` and the document's
5//! IDENTITY (its store name + the clean baseline the dirty flag compares
6//! against) lived on the file dialog. That pairing is what a document IS, so it
7//! moves here: [`Document`] = one engine + its identity, [`Documents`] = the
8//! open list + the active index.
9//!
10//! # Why the accessor lives on `Documents` and not on `BrepApp`
11//!
12//! Half the shell passes DISJOINT `&mut` borrows of its own fields in one call
13//! (`self.history.show(ui, self.docs.engine_mut(), …)`, and the whole
14//! [`DockContext`](crate::panels::dock::DockContext)). A `BrepApp::engine_mut()`
15//! would borrow ALL of `self` and every one of those sites would stop compiling.
16//! `self.docs.engine_mut()` borrows only the `docs` FIELD, so it composes with a
17//! borrow of any other field exactly as `self.state` used to.
18//!
19//! # One runner per document
20//!
21//! Each engine installs its own history runner (a native thread, or — on wasm —
22//! its own web worker), because a runner owns the RESIDENT kernel state for the
23//! document it executes: sharing one would make a background document's reply
24//! land against the wrong resident registry. The shell therefore pumps EVERY
25//! open document each frame, not just the active one, so a run that outlives a
26//! tab switch is applied to the engine that submitted it. The cost is real and
27//! deliberately unpaid-for here: N documents means N runner threads/workers and
28//! N full scenes in memory. Opening a large assembly in a second tab costs what
29//! opening it in a second window would.
30//!
31//! # Display settings are the SESSION's; the workbench is the DOCUMENT's
32//!
33//! Theme / UI scale / colors / wireframe / lod live on `EngineState.settings`,
34//! so without a rule they would silently become per-tab (change the theme, switch
35//! tabs, watch it flip back). [`carry_settings`] copies them from the outgoing
36//! engine to the incoming one on every activation, and `workbench` is EXCLUDED
37//! there: a saved document remembers the workbench it was authored in and
38//! restores it on open, which is what makes the Assembly panes appear when you
39//! switch to an assembly tab and vanish when you switch back to a part.
40//!
41//! A BRAND NEW tab is the one place the workbench IS carried — by
42//! [`Documents::spawn_engine`], BEFORE the caller loads anything into the
43//! engine, so a document that declares a workbench still overrides it during
44//! the load and one that does not (a New document, a file saved before the
45//! field existed) simply continues the session you were working in.
46
47use crate::store::ModelStore;
48use brep_render::engine_state::EngineState;
49
50/// Builds a fresh engine for a new document — the platform runner, the viewcube,
51/// and the persisted display settings, all applied before anything is loaded
52/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
53/// runner) can construct documents without a background thread.
54pub type EngineFactory = Box<dyn Fn() -> EngineState>;
55
56/// The empty model a **New** document starts from.
57pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
58
59/// ONE open model: the engine that owns it plus the identity the file lane needs
60/// — the store name it was opened from / saved to, and the clean baseline.
61pub struct Document {
62    /// The windowing-agnostic brain for THIS document (scene, camera, history,
63    /// settings, selection). Panels borrow it; it is never forked.
64    pub engine: EngineState,
65    /// The store identity (`None` = a never-saved "untitled" model). The RAW
66    /// identity, not the display name — a native dialog hands back a full path
67    /// and a plain Save must write back to it.
68    name: Option<String>,
69    /// The model request JSON as of the last New / Open / Save — the baseline
70    /// the dirty flag compares the live history against.
71    saved_signature: Option<String>,
72    /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
73    /// this is not simply `is_dirty()`.
74    dirty_marker: bool,
75    /// The applied-run generation `dirty_marker` was computed at.
76    marker_generation: Option<u64>,
77    /// A process-unique handle, so the shell can notice "the active document is
78    /// a different one now" without comparing indices (closing a tab BEFORE the
79    /// active one moves the index without changing the document).
80    id: u64,
81}
82
83impl Document {
84    /// Wrap `engine` as a document, taking its CURRENT model as the clean
85    /// baseline — so a freshly loaded (or freshly emptied) document is clean and
86    /// the first edit marks it dirty.
87    pub fn new(engine: EngineState) -> Self {
88        let saved_signature = Some(engine.history_request_json());
89        Self {
90            engine,
91            name: None,
92            saved_signature,
93            dirty_marker: false,
94            marker_generation: None,
95            id: next_document_id(),
96        }
97    }
98
99    /// Wrap `engine` as a document RESTORED from the autosave blob
100    /// (`crate::recovery`): it carries the store identity it had (`None` for
101    /// an untitled one) and NO clean baseline, so it is dirty from its first
102    /// frame — the work it holds is exactly what was never saved, and the close
103    /// guard and the tab dot must say so until a Save writes it somewhere.
104    pub fn recovered(engine: EngineState, name: Option<String>) -> Self {
105        Self {
106            engine,
107            name,
108            saved_signature: None,
109            dirty_marker: true,
110            marker_generation: None,
111            id: next_document_id(),
112        }
113    }
114
115    /// The raw store identity, or `None` for a never-saved document.
116    pub fn name(&self) -> Option<&str> {
117        self.name.as_deref()
118    }
119
120    pub fn set_name(&mut self, name: Option<String>) {
121        self.name = name;
122    }
123
124    /// The tab label: the bare display name, or `untitled`.
125    pub fn title(&self) -> String {
126        match &self.name {
127            Some(name) => crate::store::model_display_name(name),
128            None => "untitled".to_string(),
129        }
130    }
131
132    /// Snapshot the current model as the clean baseline (after New / Open /
133    /// Save, or a `?loadModel=` boot-load).
134    pub fn mark_clean(&mut self) {
135        self.saved_signature = Some(self.engine.history_request_json());
136        self.dirty_marker = false;
137        self.marker_generation = Some(self.engine.applied_generation());
138    }
139
140    /// Dirty = the live model differs from the last saved/opened baseline.
141    /// (Rolling the history does NOT change the request document, so navigating
142    /// steps never marks dirty — only real edits/add/delete/reorder do.)
143    ///
144    /// HONEST but not free: it serializes the whole request document, which for
145    /// an assembly carrying an embedded parts library is megabytes. Every place
146    /// where being wrong would cost the user work — the close prompt, Save —
147    /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
148    pub fn is_dirty(&self) -> bool {
149        match &self.saved_signature {
150            Some(saved) => *saved != self.engine.history_request_json(),
151            None => true,
152        }
153    }
154
155    /// The cached dirty flag the tab strip draws, recomputed only when the
156    /// document's APPLIED RUN generation moved. Every ordinary edit (a param
157    /// change, add, delete, reorder, undo) re-runs the history and bumps that
158    /// generation, so the dot tracks editing; a mutation that changes the
159    /// document WITHOUT a re-run (an object-metadata write) can leave the dot
160    /// one edit behind. That is a marker being briefly optimistic, never a lost
161    /// edit: [`Self::is_dirty`] is recomputed honestly at the close prompt.
162    ///
163    /// The alternative — serializing every open document every frame — is a
164    /// per-frame multi-megabyte cost per tab, and the app pays no such cost today.
165    pub fn refresh_dirty_marker(&mut self) {
166        let generation = self.engine.applied_generation();
167        if self.marker_generation == Some(generation) {
168            return;
169        }
170        self.marker_generation = Some(generation);
171        self.dirty_marker = self.is_dirty();
172    }
173
174    pub fn dirty_marker(&self) -> bool {
175        self.dirty_marker
176    }
177
178    /// The document's process-unique handle (identity across index shuffles).
179    pub fn id(&self) -> u64 {
180        self.id
181    }
182}
183
184/// Every open document + which one is active. Always holds AT LEAST ONE
185/// document: closing the last tab leaves a fresh untitled one in its place, so
186/// [`Documents::engine_mut`] is infallible and the shell never has to render a
187/// "no document" state that would be an empty viewport with extra steps.
188pub struct Documents {
189    open: Vec<Document>,
190    active: usize,
191    /// How a new tab's engine is built (platform runner + persisted settings).
192    new_engine: EngineFactory,
193}
194
195impl Documents {
196    /// A session holding ONE empty document built by `new_engine`.
197    pub fn new(new_engine: EngineFactory) -> Self {
198        let first = Document::new(new_engine());
199        Self {
200            open: vec![first],
201            active: 0,
202            new_engine,
203        }
204    }
205
206    /// A fresh engine for a document about to be opened — the caller loads into
207    /// it and hands the result to [`Self::open_document`].
208    ///
209    /// Seeded with the WHOLE of the session's settings, workbench included, so
210    /// a new tab continues what you were doing. The load that follows overrides
211    /// the workbench if the document names one (see the module header).
212    pub fn spawn_engine(&self) -> EngineState {
213        let mut engine = (self.new_engine)();
214        carry_settings(&self.engine().settings_json(), &mut engine, true);
215        engine
216    }
217
218    pub fn engine(&self) -> &EngineState {
219        &self.open[self.active].engine
220    }
221
222    pub fn engine_mut(&mut self) -> &mut EngineState {
223        &mut self.open[self.active].engine
224    }
225
226    pub fn active(&self) -> &Document {
227        &self.open[self.active]
228    }
229
230    pub fn active_mut(&mut self) -> &mut Document {
231        &mut self.open[self.active]
232    }
233
234    pub fn active_index(&self) -> usize {
235        self.active
236    }
237
238    /// The ACTIVE document's handle — the shell compares it frame to frame to
239    /// notice a switch from ANY source (a tab click, a close, New, Open, Open
240    /// Part, the session restore) with one check instead of a hook per site.
241    pub fn active_id(&self) -> u64 {
242        self.open[self.active].id
243    }
244
245    pub fn len(&self) -> usize {
246        self.open.len()
247    }
248
249    pub fn get(&self, index: usize) -> Option<&Document> {
250        self.open.get(index)
251    }
252
253    pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
254        self.open.get_mut(index)
255    }
256
257    pub fn iter(&self) -> std::slice::Iter<'_, Document> {
258        self.open.iter()
259    }
260
261    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
262        self.open.iter_mut()
263    }
264
265    /// The tab holding the document stored under `name`, if any.
266    pub fn index_of(&self, name: &str) -> Option<usize> {
267        self.open
268            .iter()
269            .position(|doc| doc.name.as_deref() == Some(name))
270    }
271
272    /// Activate the tab already holding `name`; `false` when it is not open.
273    /// The "focus the existing tab" half of open-or-focus.
274    pub fn focus_named(&mut self, name: &str) -> bool {
275        match self.index_of(name) {
276            Some(index) => {
277                self.activate(index);
278                true
279            }
280            None => false,
281        }
282    }
283
284    /// Add `doc` as a new tab and make it active.
285    pub fn open_document(&mut self, doc: Document) -> usize {
286        self.open.push(doc);
287        let index = self.open.len() - 1;
288        self.activate(index);
289        index
290    }
291
292    /// Make tab `index` active, carrying the session's display settings over to
293    /// it. Out-of-range indices are ignored (a stale click never panics).
294    pub fn activate(&mut self, index: usize) {
295        if index >= self.open.len() || index == self.active {
296            return;
297        }
298        let settings = self.open[self.active].engine.settings_json();
299        self.active = index;
300        carry_settings(&settings, &mut self.open[index].engine, false);
301    }
302
303    /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
304    /// (the always-one-document invariant); closing a tab before the active one
305    /// keeps the same document active at its new index.
306    ///
307    /// The caller is responsible for the unsaved-changes prompt — this is the
308    /// mechanical close (`panels::file` owns the confirmation).
309    pub fn close(&mut self, index: usize) {
310        if index >= self.open.len() {
311            return;
312        }
313        // Taken BEFORE the removal: if the closing tab was the active one, its
314        // settings are the session's and must survive into whatever is shown next.
315        let settings = self.open[self.active].engine.settings_json();
316        let closed_active = index == self.active;
317        self.open.remove(index);
318        // The replacement for a session closed down to nothing is a brand-new
319        // tab, so it takes the WHOLE of the session's settings — workbench
320        // included — exactly as `New` does through `spawn_engine`.
321        let refilled = self.open.is_empty();
322        if refilled {
323            self.open.push(Document::new((self.new_engine)()));
324            self.active = 0;
325        } else if closed_active {
326            self.active = index.min(self.open.len() - 1);
327        } else if index < self.active {
328            self.active -= 1;
329        }
330        if closed_active {
331            let active = self.active;
332            carry_settings(&settings, &mut self.open[active].engine, refilled);
333        }
334    }
335
336    /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
337    pub fn refresh_dirty_markers(&mut self) {
338        for doc in &mut self.open {
339            doc.refresh_dirty_marker();
340        }
341    }
342
343    // --- session persistence --------------------------------------------------
344
345    /// The persisted session: the NAMED documents in tab order plus the active
346    /// one's name. A never-saved document has no store identity, so it cannot be
347    /// restored and is not listed — reloading loses an unsaved scratch document
348    /// exactly as it did when the app held one document and persisted none.
349    pub fn session_json(&self) -> String {
350        let open: Vec<&str> = self.open.iter().filter_map(Document::name).collect();
351        serde_json::json!({
352            "open": open,
353            "active": self.active().name(),
354        })
355        .to_string()
356    }
357
358    /// Restore a persisted session, REPLACING the current open list. Names the
359    /// store no longer holds (or cannot load) are skipped — a deleted file must
360    /// not stop the rest of the session coming back — and the count of documents
361    /// actually restored is returned, so the caller can seed instead when it is 0.
362    pub fn restore_session(&mut self, store: &dyn ModelStore, json: &str) -> usize {
363        let Ok(session) = serde_json::from_str::<serde_json::Value>(json) else {
364            return 0;
365        };
366        let names: Vec<String> = session["open"]
367            .as_array()
368            .map(|list| {
369                list.iter()
370                    .filter_map(|name| name.as_str().map(str::to_string))
371                    .collect()
372            })
373            .unwrap_or_default();
374        let wanted_active = session["active"].as_str().unwrap_or_default().to_string();
375
376        let mut restored: Vec<Document> = Vec::new();
377        let mut active = 0usize;
378        for name in names {
379            let Some(contents) = store.read(&name) else {
380                continue;
381            };
382            let mut engine = (self.new_engine)();
383            if engine.load_model_and_fit(&contents).is_err() {
384                continue;
385            }
386            let mut doc = Document::new(engine);
387            doc.set_name(Some(name.clone()));
388            if name == wanted_active {
389                active = restored.len();
390            }
391            restored.push(doc);
392        }
393        if restored.is_empty() {
394            return 0;
395        }
396        self.open = restored;
397        self.active = active;
398        self.open.len()
399    }
400}
401
402/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
403/// projection, lod…) from a `settings_json()` snapshot into `target`.
404/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
405/// module header for why activation must never carry it.
406fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
407    let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
408        return;
409    };
410    if !with_workbench {
411        if let Some(object) = settings.as_object_mut() {
412            object.remove("workbench");
413        }
414    }
415    let _ = target.apply_settings_json(&settings.to_string());
416}
417
418/// Hand out the next document handle. A plain process counter: handles only ever
419/// need to be distinct WITHIN a session, and they never reach storage.
420fn next_document_id() -> u64 {
421    use std::sync::atomic::{AtomicU64, Ordering};
422    static NEXT: AtomicU64 = AtomicU64::new(1);
423    NEXT.fetch_add(1, Ordering::Relaxed)
424}
425
426#[cfg(all(test, not(target_arch = "wasm32")))]
427mod tests {
428    use super::*;
429    use crate::store::MemModelStore;
430
431    /// The test factory: the SYNCHRONOUS inline runner, so a load's solids are
432    /// resident by the time the call returns.
433    fn documents() -> Documents {
434        Documents::new(Box::new(EngineState::new))
435    }
436
437    const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
438        {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
439         "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
440         "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
441    ]}"#;
442
443    fn named(docs: &mut Documents, name: &str) -> usize {
444        let mut engine = docs.spawn_engine();
445        engine.set_history_json(BOX_SEED).unwrap();
446        let mut doc = Document::new(engine);
447        doc.set_name(Some(name.to_string()));
448        docs.open_document(doc)
449    }
450
451    /// A session starts with exactly one (empty, untitled, clean) document, and
452    /// every opened document becomes the active tab.
453    #[test]
454    fn a_session_always_holds_at_least_one_document() {
455        let mut docs = documents();
456        assert_eq!(docs.len(), 1);
457        assert_eq!(docs.active_index(), 0);
458        assert_eq!(docs.active().title(), "untitled");
459        assert!(!docs.active().is_dirty(), "a fresh document is clean");
460
461        named(&mut docs, "part-a");
462        assert_eq!(docs.len(), 2);
463        assert_eq!(docs.active_index(), 1, "an opened document takes focus");
464        assert_eq!(docs.active().title(), "part-a");
465    }
466
467    /// Open-or-focus: a name already open activates ITS tab instead of adding a
468    /// second one.
469    #[test]
470    fn focus_named_activates_the_existing_tab() {
471        let mut docs = documents();
472        named(&mut docs, "part-a");
473        named(&mut docs, "part-b");
474        assert_eq!(docs.active_index(), 2);
475
476        assert!(docs.focus_named("part-a"), "already open");
477        assert_eq!(docs.active_index(), 1);
478        assert_eq!(docs.len(), 3, "focusing never adds a tab");
479        assert!(!docs.focus_named("part-z"), "never opened");
480        assert_eq!(docs.active_index(), 1, "a miss leaves the focus alone");
481    }
482
483    /// The index fixup: closing a tab BEFORE the active one keeps the same
484    /// document active (its handle is unchanged); closing the active one falls
485    /// to its neighbour; closing the last one leaves a fresh untitled document.
486    #[test]
487    fn closing_fixes_up_the_active_index() {
488        let mut docs = documents();
489        named(&mut docs, "part-a"); // index 1
490        named(&mut docs, "part-b"); // index 2
491        docs.activate(2);
492        let active_id = docs.active_id();
493
494        // Close the untitled tab BEFORE the active one: same document, new index.
495        docs.close(0);
496        assert_eq!(docs.len(), 2);
497        assert_eq!(docs.active_index(), 1);
498        assert_eq!(docs.active_id(), active_id, "the same document stays active");
499        assert_eq!(docs.active().title(), "part-b");
500
501        // Close the ACTIVE (last) tab: focus falls back to its neighbour.
502        docs.close(1);
503        assert_eq!(docs.len(), 1);
504        assert_eq!(docs.active_index(), 0);
505        assert_eq!(docs.active().title(), "part-a");
506        assert_ne!(docs.active_id(), active_id, "a different document is active");
507
508        // Close the ONLY tab: a fresh untitled document takes its place.
509        docs.close(0);
510        assert_eq!(docs.len(), 1, "there is always a document");
511        assert_eq!(docs.active().title(), "untitled");
512        assert_eq!(docs.active().engine.history_len(), 0);
513
514        // Out-of-range indices are ignored rather than panicking.
515        docs.close(7);
516        assert_eq!(docs.len(), 1);
517    }
518
519    /// Dirty tracking is per document: editing one tab never marks another, and
520    /// the cached tab-strip marker follows the honest flag across a re-run.
521    #[test]
522    fn dirty_tracking_is_per_document() {
523        let mut docs = documents();
524        named(&mut docs, "part-a");
525        named(&mut docs, "part-b");
526        docs.refresh_dirty_markers();
527        assert!(docs.iter().all(|doc| !doc.is_dirty()), "all clean");
528        assert!(docs.iter().all(|doc| !doc.dirty_marker()));
529
530        // Edit the ACTIVE document (part-b).
531        docs.engine_mut()
532            .update_feature_params(
533                "Box",
534                r#"{"id":"Box","sizeX":19.0,"sizeY":8.0,"sizeZ":8.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
535            )
536            .unwrap();
537        docs.refresh_dirty_markers();
538        assert!(docs.active().is_dirty(), "the edited document is dirty");
539        assert!(docs.active().dirty_marker(), "…and its tab dot lights");
540        assert!(!docs.get(1).unwrap().is_dirty(), "the other tab is untouched");
541        assert!(!docs.get(1).unwrap().dirty_marker());
542
543        // Saving (marking clean) clears both the flag and the dot.
544        docs.active_mut().mark_clean();
545        docs.refresh_dirty_markers();
546        assert!(!docs.active().is_dirty());
547        assert!(!docs.active().dirty_marker());
548    }
549
550    /// Display settings are the SESSION's: a theme change follows a tab switch.
551    /// The workbench is the DOCUMENT's and must NOT follow it — that is what
552    /// makes the Assembly panes appear on an assembly tab and vanish on a part.
553    #[test]
554    fn switching_carries_display_settings_but_not_the_workbench() {
555        let mut docs = documents();
556        docs.engine_mut()
557            .apply_settings_json(r#"{"workbench":"assembly","wireframe":true}"#)
558            .unwrap();
559        named(&mut docs, "part-a");
560        docs.engine_mut()
561            .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
562            .unwrap();
563        assert!(
564            docs.engine().settings.wireframe,
565            "the new tab inherited the session's display settings"
566        );
567
568        // Back to the first tab: it keeps its own workbench, and picks up the
569        // display setting changed while it was in the background.
570        docs.engine_mut()
571            .apply_settings_json(r#"{"wireframe":false}"#)
572            .unwrap();
573        docs.activate(0);
574        assert_eq!(docs.engine().settings.workbench, "assembly", "per document");
575        assert!(!docs.engine().settings.wireframe, "per session");
576
577        docs.activate(1);
578        assert_eq!(docs.engine().settings.workbench, "sheetMetal");
579    }
580
581    /// The session round-trips through the store: only NAMED documents are
582    /// listed, the active one comes back active, and a name whose file has gone
583    /// is skipped rather than failing the whole restore.
584    #[test]
585    fn the_session_round_trips_and_tolerates_a_missing_file() {
586        let store = MemModelStore::new();
587        store.put("part-a", BOX_SEED);
588        store.put("part-b", BOX_SEED);
589
590        let mut docs = documents();
591        named(&mut docs, "part-a");
592        named(&mut docs, "part-b");
593        docs.activate(1);
594        let session = docs.session_json();
595        let parsed: serde_json::Value = serde_json::from_str(&session).unwrap();
596        assert_eq!(
597            parsed["open"].as_array().unwrap().len(),
598            2,
599            "the untitled tab is not persistable: {session}"
600        );
601        assert_eq!(parsed["active"], "part-a");
602
603        let mut restored = documents();
604        assert_eq!(restored.restore_session(&store, &session), 2);
605        assert_eq!(restored.len(), 2);
606        assert_eq!(restored.active().title(), "part-a");
607        assert!(!restored.active().is_dirty(), "a restored document is clean");
608        assert_eq!(restored.active().engine.scene.solids().len(), 1);
609
610        // One file removed: the rest of the session still comes back.
611        store.remove("part-a").unwrap();
612        let mut partial = documents();
613        assert_eq!(partial.restore_session(&store, &session), 1);
614        assert_eq!(partial.active().title(), "part-b");
615
616        // Nothing restorable → 0, and the caller seeds instead.
617        store.remove("part-b").unwrap();
618        let mut none = documents();
619        assert_eq!(none.restore_session(&store, &session), 0);
620        assert_eq!(none.len(), 1, "the untouched boot document survives");
621        assert_eq!(none.restore_session(&store, "not json"), 0);
622    }
623}