git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

commit252827c1f6033514fbed46d1c7c1632241344fce
parent11a26a5f8e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:56
canvas: number fields, field removal, and editable node/edge lists

Object::num / set_num (whole numbers written as integers, as Obsidian
writes coordinates; an equal value leaves the original formatting) and
remove; Canvas::empty, edges, and objects_mut (the nodes or edges array,
created when missing). What cce-grid needs to keep the desktop as a JSON
Canvas while giving back every field and node it did not touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/canvas.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 95 insertions(+)

diff --git a/src/canvas.rs b/src/canvas.rs
index ff6fd81..9f9cccc 100644
--- a/src/canvas.rs
+++ b/src/canvas.rs
@@ -55,7 +55,17 @@ impl<'de> Deserialize<'de> for Object {
     }
 }
 
+impl Default for Object {
+    fn default() -> Object {
+        Object::new()
+    }
+}
+
 impl Object {
+    pub fn new() -> Object {
+        Object(Vec::new())
+    }
+
     fn raw(&self, key: &str) -> Option<&RawValue> {
         self.0.iter().find(|(k, _)| k == key).map(|(_, v)| &**v)
     }
@@ -74,6 +84,34 @@ impl Object {
         }
     }
 
+    /// A number field's value.
+    pub fn num(&self, key: &str) -> Option<f64> {
+        serde_json::from_str::<f64>(self.raw(key)?.get()).ok()
+    }
+
+    /// Set a number field, written as an integer when it is whole (as
+    /// Obsidian writes coordinates). A value equal to the one there is left
+    /// alone, so an untouched field keeps its original formatting.
+    pub fn set_num(&mut self, key: &str, value: f64) {
+        if !value.is_finite() || self.num(key) == Some(value) {
+            return;
+        }
+        let text = if value.fract() == 0.0 && value.abs() < 1e15 {
+            format!("{}", value as i64)
+        } else {
+            format!("{value}")
+        };
+        let raw = RawValue::from_string(text).expect("a finite number is valid JSON");
+        match self.0.iter_mut().find(|(k, _)| k == key) {
+            Some((_, v)) => *v = raw,
+            None => self.0.push((key.to_string(), raw)),
+        }
+    }
+
+    pub fn remove(&mut self, key: &str) {
+        self.0.retain(|(k, _)| k != key);
+    }
+
     fn write_compact(&self, out: &mut String) {
         out.push('{');
         for (i, (k, v)) in self.0.iter().enumerate() {
@@ -152,6 +190,38 @@ pub fn to_string(canvas: &Canvas) -> String {
 }
 
 impl Canvas {
+    /// `{"nodes":[],"edges":[]}`, as Obsidian starts a new canvas.
+    pub fn empty() -> Canvas {
+        Canvas { fields: vec![("nodes".into(), Field::Objects(Vec::new())), ("edges".into(), Field::Objects(Vec::new()))] }
+    }
+
+    /// The `name` array of objects (`nodes`, `edges`), created empty at the
+    /// end when the canvas has none. A field of that name that is not an
+    /// array of objects is replaced.
+    pub fn objects_mut(&mut self, name: &str) -> &mut Vec<Object> {
+        let at = match self.fields.iter().position(|(k, _)| k == name) {
+            Some(i) => i,
+            None => {
+                self.fields.push((name.to_string(), Field::Objects(Vec::new())));
+                self.fields.len() - 1
+            }
+        };
+        if !matches!(self.fields[at].1, Field::Objects(_)) {
+            self.fields[at].1 = Field::Objects(Vec::new());
+        }
+        match &mut self.fields[at].1 {
+            Field::Objects(list) => list,
+            Field::Raw(_) => unreachable!("replaced above"),
+        }
+    }
+
+    pub fn edges(&self) -> impl Iterator<Item = &Object> {
+        self.fields.iter().filter(|(k, _)| k == "edges").flat_map(|(_, f)| match f {
+            Field::Objects(list) => list.iter(),
+            Field::Raw(_) => Default::default(),
+        })
+    }
+
     pub fn nodes(&self) -> impl Iterator<Item = &Object> {
         self.fields.iter().filter(|(k, _)| k == "nodes").flat_map(|(_, f)| match f {
             Field::Objects(list) => list.iter(),
@@ -246,6 +316,31 @@ mod tests {
         assert!(checked > 0, "no canvases found");
     }
 
+    #[test]
+    fn numbers_and_lists_edit_in_place() {
+        let mut canvas = from_str(SAMPLE).unwrap();
+        let b2 = canvas.objects_mut("nodes").iter_mut().find(|n| n.str("id").as_deref() == Some("b2")).unwrap();
+        assert_eq!(b2.num("x"), Some(0.5));
+        assert_eq!(b2.num("y"), Some(100.0));
+        // Equal: untouched, keeps `1e2`. Whole: written as an integer.
+        b2.set_num("y", 100.0);
+        b2.set_num("x", 12.0);
+        b2.remove("color");
+        let out = to_string(&canvas);
+        assert!(out.contains(r##"{"id":"b2","type":"file","file":"notes/Beta.md","subpath":"#Part","x":12,"y":1e2,"width":400,"height":400}"##), "{out}");
+        let mut fresh = Canvas::empty();
+        let mut node = Object::new();
+        node.set_str("id", "n");
+        node.set_num("x", -3.25);
+        fresh.objects_mut("nodes").push(node);
+        assert_eq!(to_string(&fresh), "{\n\t\"nodes\":[\n\t\t{\"id\":\"n\",\"x\":-3.25}\n\t],\n\t\"edges\":[]\n}");
+        assert_eq!(canvas.edges().count(), 1);
+        // A canvas without edges gets the array on first use.
+        let mut bare = from_str("{\"nodes\":[]}").unwrap();
+        bare.objects_mut("edges").push(Object::new());
+        assert_eq!(bare.edges().count(), 1);
+    }
+
     #[test]
     fn index_file_and_text_nodes() {
         let note = index(&from_str(SAMPLE).unwrap());