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 }