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

src/canvas.rs (13.6K)

  1 //! JSON Canvas (<https://jsoncanvas.org>), the format behind Obsidian's
  2 //! `.canvas` files and — from milestone 5 — the cce desktop's own boards.
  3 //!
  4 //! A canvas is held as ordered fields whose values stay as the raw JSON
  5 //! text they were read from (`serde_json::value::RawValue`), never as typed
  6 //! structs or a parsed `Value`. A write must give back every key it did not
  7 //! touch, in its original order and with its original number formatting,
  8 //! including keys a newer Obsidian adds. [`to_string`] reproduces
  9 //! Obsidian's layout byte for byte (tabs, one compact node per line, no
 10 //! trailing newline), so a rename touching a canvas shows up in a diff as
 11 //! the one field it changed.
 12 //!
 13 //! Not `serde_json`'s `preserve_order` feature: features unify across the
 14 //! workspace, and turning it on here would switch every other crate's JSON
 15 //! maps from sorted to insertion order in a `--workspace` build — the
 16 //! compositor's `state.json` included.
 17 
 18 use serde::de::{Deserialize, Deserializer, MapAccess, Visitor};
 19 use serde_json::value::RawValue;
 20 
 21 use crate::parse::{self, Link, LinkKind, Note};
 22 
 23 #[derive(Debug)]
 24 pub struct CanvasError(pub String);
 25 
 26 impl std::fmt::Display for CanvasError {
 27     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 28         write!(f, "invalid canvas: {}", self.0)
 29     }
 30 }
 31 
 32 impl std::error::Error for CanvasError {}
 33 
 34 /// One JSON object as ordered (key, raw value) pairs.
 35 #[derive(Debug, Clone)]
 36 pub struct Object(Vec<(String, Box<RawValue>)>);
 37 
 38 impl<'de> Deserialize<'de> for Object {
 39     fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
 40         struct V;
 41         impl<'de> Visitor<'de> for V {
 42             type Value = Object;
 43             fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
 44                 f.write_str("a JSON object")
 45             }
 46             fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Object, A::Error> {
 47                 let mut out = Vec::new();
 48                 while let Some((k, v)) = map.next_entry::<String, Box<RawValue>>()? {
 49                     out.push((k, v));
 50                 }
 51                 Ok(Object(out))
 52             }
 53         }
 54         d.deserialize_map(V)
 55     }
 56 }
 57 
 58 impl Default for Object {
 59     fn default() -> Object {
 60         Object::new()
 61     }
 62 }
 63 
 64 impl Object {
 65     pub fn new() -> Object {
 66         Object(Vec::new())
 67     }
 68 
 69     fn raw(&self, key: &str) -> Option<&RawValue> {
 70         self.0.iter().find(|(k, _)| k == key).map(|(_, v)| &**v)
 71     }
 72 
 73     /// A string field's value.
 74     pub fn str(&self, key: &str) -> Option<String> {
 75         serde_json::from_str::<String>(self.raw(key)?.get()).ok()
 76     }
 77 
 78     /// Set a string field in place, or append it when missing.
 79     pub fn set_str(&mut self, key: &str, value: &str) {
 80         let raw = serde_json::value::to_raw_value(value).expect("a string always serialises");
 81         match self.0.iter_mut().find(|(k, _)| k == key) {
 82             Some((_, v)) => *v = raw,
 83             None => self.0.push((key.to_string(), raw)),
 84         }
 85     }
 86 
 87     /// A number field's value.
 88     pub fn num(&self, key: &str) -> Option<f64> {
 89         serde_json::from_str::<f64>(self.raw(key)?.get()).ok()
 90     }
 91 
 92     /// Set a number field, written as an integer when it is whole (as
 93     /// Obsidian writes coordinates). A value equal to the one there is left
 94     /// alone, so an untouched field keeps its original formatting.
 95     pub fn set_num(&mut self, key: &str, value: f64) {
 96         if !value.is_finite() || self.num(key) == Some(value) {
 97             return;
 98         }
 99         let text = if value.fract() == 0.0 && value.abs() < 1e15 {
100             format!("{}", value as i64)
101         } else {
102             format!("{value}")
103         };
104         let raw = RawValue::from_string(text).expect("a finite number is valid JSON");
105         match self.0.iter_mut().find(|(k, _)| k == key) {
106             Some((_, v)) => *v = raw,
107             None => self.0.push((key.to_string(), raw)),
108         }
109     }
110 
111     pub fn remove(&mut self, key: &str) {
112         self.0.retain(|(k, _)| k != key);
113     }
114 
115     fn write_compact(&self, out: &mut String) {
116         out.push('{');
117         for (i, (k, v)) in self.0.iter().enumerate() {
118             if i > 0 {
119                 out.push(',');
120             }
121             out.push_str(&serde_json::to_string(k).unwrap_or_default());
122             out.push(':');
123             out.push_str(v.get());
124         }
125         out.push('}');
126     }
127 }
128 
129 #[derive(Debug, Clone)]
130 enum Field {
131     /// An array of objects (`nodes`, `edges`): written one per line.
132     Objects(Vec<Object>),
133     /// Anything else, kept exactly as read.
134     Raw(Box<RawValue>),
135 }
136 
137 #[derive(Debug, Clone)]
138 pub struct Canvas {
139     fields: Vec<(String, Field)>,
140 }
141 
142 pub fn from_str(src: &str) -> Result<Canvas, CanvasError> {
143     let top: Object = serde_json::from_str(src).map_err(|e| CanvasError(e.to_string()))?;
144     let fields = top
145         .0
146         .into_iter()
147         .map(|(k, v)| {
148             let field = match serde_json::from_str::<Vec<Object>>(v.get()) {
149                 Ok(list) if v.get().trim_start().starts_with('[') => Field::Objects(list),
150                 _ => Field::Raw(v),
151             };
152             (k, field)
153         })
154         .collect();
155     Ok(Canvas { fields })
156 }
157 
158 /// Obsidian's layout: `JSON.stringify` with a tab indent at the top level
159 /// and each array element compact on its own line.
160 pub fn to_string(canvas: &Canvas) -> String {
161     let mut out = String::from("{\n");
162     let n = canvas.fields.len();
163     for (i, (key, field)) in canvas.fields.iter().enumerate() {
164         out.push('\t');
165         out.push_str(&serde_json::to_string(key).unwrap_or_default());
166         out.push(':');
167         match field {
168             Field::Objects(items) if !items.is_empty() => {
169                 out.push_str("[\n");
170                 for (j, item) in items.iter().enumerate() {
171                     out.push_str("\t\t");
172                     item.write_compact(&mut out);
173                     if j + 1 < items.len() {
174                         out.push(',');
175                     }
176                     out.push('\n');
177                 }
178                 out.push_str("\t]");
179             }
180             Field::Objects(_) => out.push_str("[]"),
181             Field::Raw(v) => out.push_str(v.get()),
182         }
183         if i + 1 < n {
184             out.push(',');
185         }
186         out.push('\n');
187     }
188     out.push('}');
189     out
190 }
191 
192 impl Canvas {
193     /// `{"nodes":[],"edges":[]}`, as Obsidian starts a new canvas.
194     pub fn empty() -> Canvas {
195         Canvas { fields: vec![("nodes".into(), Field::Objects(Vec::new())), ("edges".into(), Field::Objects(Vec::new()))] }
196     }
197 
198     /// The `name` array of objects (`nodes`, `edges`), created empty at the
199     /// end when the canvas has none. A field of that name that is not an
200     /// array of objects is replaced.
201     pub fn objects_mut(&mut self, name: &str) -> &mut Vec<Object> {
202         let at = match self.fields.iter().position(|(k, _)| k == name) {
203             Some(i) => i,
204             None => {
205                 self.fields.push((name.to_string(), Field::Objects(Vec::new())));
206                 self.fields.len() - 1
207             }
208         };
209         if !matches!(self.fields[at].1, Field::Objects(_)) {
210             self.fields[at].1 = Field::Objects(Vec::new());
211         }
212         match &mut self.fields[at].1 {
213             Field::Objects(list) => list,
214             Field::Raw(_) => unreachable!("replaced above"),
215         }
216     }
217 
218     pub fn edges(&self) -> impl Iterator<Item = &Object> {
219         self.fields.iter().filter(|(k, _)| k == "edges").flat_map(|(_, f)| match f {
220             Field::Objects(list) => list.iter(),
221             Field::Raw(_) => Default::default(),
222         })
223     }
224 
225     pub fn nodes(&self) -> impl Iterator<Item = &Object> {
226         self.fields.iter().filter(|(k, _)| k == "nodes").flat_map(|(_, f)| match f {
227             Field::Objects(list) => list.iter(),
228             Field::Raw(_) => Default::default(),
229         })
230     }
231 
232     pub fn nodes_mut(&mut self) -> impl Iterator<Item = &mut Object> {
233         self.fields.iter_mut().filter(|(k, _)| k == "nodes").flat_map(|(_, f)| match f {
234             Field::Objects(list) => list.iter_mut(),
235             Field::Raw(_) => Default::default(),
236         })
237     }
238 }
239 
240 /// A canvas as the index sees it: `file` nodes are embeds of that file,
241 /// and `text` nodes are small notes whose links, tags and tasks count.
242 /// Link spans on text-node links are offsets into that node's `text`.
243 pub fn index(canvas: &Canvas) -> Note {
244     let mut note = Note::default();
245     for node in canvas.nodes() {
246         let id = node.str("id").unwrap_or_default();
247         match node.str("type").as_deref() {
248             Some("file") => {
249                 let Some(file) = node.str("file") else { continue };
250                 note.links.push(Link {
251                     kind: LinkKind::CanvasFile,
252                     embed: true,
253                     target: file,
254                     subpath: node
255                         .str("subpath")
256                         .map(|s| s.trim_start_matches('#').to_string())
257                         .filter(|s| !s.is_empty()),
258                     display: None,
259                     span: 0..0,
260                     target_span: 0..0,
261                     line: 0,
262                     node: Some(id),
263                 });
264             }
265             Some("text") => {
266                 let text = node.str("text").unwrap_or_default();
267                 let inner = parse::parse(&text);
268                 note.links.extend(inner.links.into_iter().map(|mut l| {
269                     l.node = Some(id.clone());
270                     l
271                 }));
272                 note.tags.extend(inner.tags);
273                 note.tasks.extend(inner.tasks);
274             }
275             _ => {}
276         }
277     }
278     note
279 }
280 
281 #[cfg(test)]
282 mod tests {
283     use super::*;
284 
285     const SAMPLE: &str = "{\n\t\"nodes\":[\n\
286         \t\t{\"id\":\"a1\",\"x\":18,\"y\":-193,\"width\":250,\"height\":60,\"type\":\"text\",\"text\":\"see [[Alpha]] #idea\\n- [ ] do it\"},\n\
287         \t\t{\"id\":\"b2\",\"type\":\"file\",\"file\":\"notes/Beta.md\",\"subpath\":\"#Part\",\"x\":0.50,\"y\":1e2,\"width\":400,\"height\":400,\"color\":\"4\"},\n\
288         \t\t{\"id\":\"c3\",\"type\":\"link\",\"url\":\"https://example.com\",\"x\":0,\"y\":0,\"width\":1,\"height\":1,\"future\":{\"b\":1,\"a\":[2]}}\n\
289         \t],\n\t\"edges\":[\n\
290         \t\t{\"id\":\"e1\",\"fromNode\":\"a1\",\"fromSide\":\"right\",\"toNode\":\"b2\",\"toSide\":\"left\"}\n\
291         \t],\n\t\"zeta\":{\"keep\": \"as written\"}\n}";
292 
293     #[test]
294     fn round_trip_is_byte_exact() {
295         let canvas = from_str(SAMPLE).unwrap();
296         assert_eq!(to_string(&canvas), SAMPLE);
297         let empty = "{\n\t\"nodes\":[],\n\t\"edges\":[]\n}";
298         assert_eq!(to_string(&from_str(empty).unwrap()), empty);
299         assert!(from_str("[1]").is_err());
300     }
301 
302     /// Every `.canvas` under `$CCE_CANVAS_DIR` must survive a read and a
303     /// write unchanged: `CCE_CANVAS_DIR=~/vault cargo test -- --ignored`.
304     #[test]
305     #[ignore]
306     fn real_canvases_round_trip() {
307         let dir = std::env::var("CCE_CANVAS_DIR").expect("set CCE_CANVAS_DIR");
308         let mut checked = 0;
309         for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
310             if entry.path().extension().is_some_and(|e| e == "canvas") {
311                 let text = std::fs::read_to_string(entry.path()).unwrap();
312                 assert_eq!(to_string(&from_str(&text).unwrap()), text, "{}", entry.path().display());
313                 checked += 1;
314             }
315         }
316         assert!(checked > 0, "no canvases found");
317     }
318 
319     #[test]
320     fn numbers_and_lists_edit_in_place() {
321         let mut canvas = from_str(SAMPLE).unwrap();
322         let b2 = canvas.objects_mut("nodes").iter_mut().find(|n| n.str("id").as_deref() == Some("b2")).unwrap();
323         assert_eq!(b2.num("x"), Some(0.5));
324         assert_eq!(b2.num("y"), Some(100.0));
325         // Equal: untouched, keeps `1e2`. Whole: written as an integer.
326         b2.set_num("y", 100.0);
327         b2.set_num("x", 12.0);
328         b2.remove("color");
329         let out = to_string(&canvas);
330         assert!(out.contains(r##"{"id":"b2","type":"file","file":"notes/Beta.md","subpath":"#Part","x":12,"y":1e2,"width":400,"height":400}"##), "{out}");
331         let mut fresh = Canvas::empty();
332         let mut node = Object::new();
333         node.set_str("id", "n");
334         node.set_num("x", -3.25);
335         fresh.objects_mut("nodes").push(node);
336         assert_eq!(to_string(&fresh), "{\n\t\"nodes\":[\n\t\t{\"id\":\"n\",\"x\":-3.25}\n\t],\n\t\"edges\":[]\n}");
337         assert_eq!(canvas.edges().count(), 1);
338         // A canvas without edges gets the array on first use.
339         let mut bare = from_str("{\"nodes\":[]}").unwrap();
340         bare.objects_mut("edges").push(Object::new());
341         assert_eq!(bare.edges().count(), 1);
342     }
343 
344     #[test]
345     fn index_file_and_text_nodes() {
346         let note = index(&from_str(SAMPLE).unwrap());
347         let got: Vec<_> = note
348             .links
349             .iter()
350             .map(|l| (l.target.as_str(), l.kind, l.node.as_deref(), l.subpath.as_deref()))
351             .collect();
352         assert_eq!(
353             got,
354             [
355                 ("Alpha", LinkKind::Wiki, Some("a1"), None),
356                 ("notes/Beta.md", LinkKind::CanvasFile, Some("b2"), Some("Part")),
357             ]
358         );
359         assert_eq!(note.tags[0].name, "idea");
360         assert_eq!(note.tasks[0].text, "do it");
361     }
362 
363     #[test]
364     fn edit_changes_only_that_field() {
365         let mut canvas = from_str(SAMPLE).unwrap();
366         for node in canvas.nodes_mut() {
367             if node.str("file").as_deref() == Some("notes/Beta.md") {
368                 node.set_str("file", "notes/Gamma \"quoted\".md");
369             }
370         }
371         let expected = SAMPLE.replace("notes/Beta.md", "notes/Gamma \\\"quoted\\\".md");
372         assert_eq!(to_string(&canvas), expected);
373     }
374 }