git.lucas.co / cce-grid
desktop grid client
git clone https://git.lucas.co/cce-grid.git

src/board.rs (17.9K)

  1 // The desktop board: everything pinned to the canvas, kept as a JSON Canvas
  2 // file (https://jsoncanvas.org, Obsidian's `.canvas`) — `Desktop.canvas` at
  3 // the root of the notes vault when one is configured, so Obsidian and the
  4 // user's other devices see the desktop as a canvas, else
  5 // `$XDG_DATA_HOME/cce/desktop.canvas`.
  6 //
  7 // Nodes map to desktop items one to one (JSON Canvas node order is z-order,
  8 // as the items list's is): `file` nodes are images, note cards or plain
  9 // file cards by extension; `text`, `link` and `group` nodes are their own
 10 // kinds; any type this client does not know is kept, undrawn, so a newer
 11 // Obsidian's nodes survive a save. Edges are kept with their ids, sides,
 12 // labels and colours.
 13 //
 14 // A save edits the canvas it read (cce_vault::canvas keeps every field as
 15 // the raw text it came as) rather than writing a fresh one: fields and
 16 // keys this client does not model — a node's colour Obsidian set, a key a
 17 // newer version adds — go back exactly as they were.
 18 //
 19 // File paths: vault-relative for a file inside the vault (what Obsidian
 20 // reads), absolute for one outside it — the desktop folder's images, which
 21 // Obsidian then shows as missing while cce draws them.
 22 //
 23 // The first run with a board migrates the old sidecar
 24 // (`desktop-items.json`, images only) into it and renames the sidecar to
 25 // `desktop-items.json.migrated`.
 26 
 27 use std::path::{Path, PathBuf};
 28 
 29 use cce_vault::canvas::{self, Canvas, Object};
 30 
 31 use crate::items::{kind_for_file, DesktopItem, Kind};
 32 
 33 #[derive(Debug, Clone, PartialEq)]
 34 pub struct Edge {
 35     pub id: String,
 36     pub from: String,
 37     pub to: String,
 38     pub from_side: Option<String>,
 39     pub to_side: Option<String>,
 40     pub label: Option<String>,
 41     /// An arrowhead at the `to` end (JSON Canvas's default `toEnd`).
 42     pub arrow: bool,
 43 }
 44 
 45 pub struct Board {
 46     pub path: PathBuf,
 47     /// The vault root, when the board lives in one.
 48     pub vault: Option<PathBuf>,
 49     canvas: Canvas,
 50     /// The board file's mtime after this client's last write or read, so
 51     /// a change it did not make itself can be told apart.
 52     pub seen: Option<std::time::SystemTime>,
 53 }
 54 
 55 /// Where the board lives: the vault's `Desktop.canvas`, else the data dir.
 56 pub fn location() -> (PathBuf, Option<PathBuf>) {
 57     match cce_vault::config::vault_root(None) {
 58         Ok(vault) => (vault.join("Desktop.canvas"), Some(vault)),
 59         Err(_) => (cce_ui::config::data_home().join("cce").join("desktop.canvas"), None),
 60     }
 61 }
 62 
 63 /// The pre-board sidecar: images only.
 64 pub fn legacy_path() -> PathBuf {
 65     cce_ui::config::data_home().join("cce").join("desktop-items.json")
 66 }
 67 
 68 #[derive(serde::Deserialize)]
 69 struct LegacyItem {
 70     path: PathBuf,
 71     x: f64,
 72     y: f64,
 73     w: f64,
 74     h: f64,
 75 }
 76 
 77 /// A fresh node id in Obsidian's shape: 16 hex digits.
 78 pub fn new_id() -> String {
 79     use std::hash::{BuildHasher, Hasher};
 80     static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
 81     let mut h = std::collections::hash_map::RandomState::new().build_hasher();
 82     h.write_u128(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));
 83     h.write_u64(COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
 84     format!("{:016x}", h.finish())
 85 }
 86 
 87 fn mtime(path: &Path) -> Option<std::time::SystemTime> {
 88     std::fs::metadata(path).and_then(|m| m.modified()).ok()
 89 }
 90 
 91 impl Board {
 92     /// The board at its configured place, migrating the old sidecar into
 93     /// it the first time.
 94     pub fn open() -> (Board, Vec<DesktopItem>, Vec<Edge>) {
 95         let (path, vault) = location();
 96         Board::open_at(path, vault, &legacy_path())
 97     }
 98 
 99     pub fn open_at(path: PathBuf, vault: Option<PathBuf>, legacy: &Path) -> (Board, Vec<DesktopItem>, Vec<Edge>) {
100         let mut board = Board { path, vault, canvas: Canvas::empty(), seen: None };
101         if board.path.exists() {
102             match board.read() {
103                 Ok((items, edges)) => return (board, items, edges),
104                 Err(e) => {
105                     // A board that does not parse must not be overwritten by
106                     // an empty one: run with nothing and leave the file be.
107                     log::error!("[board] {} is unreadable ({e}); not loading or rewriting it", board.path.display());
108                     board.seen = None;
109                     board.path = PathBuf::new();
110                     return (board, Vec::new(), Vec::new());
111                 }
112             }
113         }
114         let items = migrate(legacy);
115         if !items.is_empty() {
116             board.save(&items, &[]);
117             if board.seen.is_some() {
118                 let done = legacy.with_extension("json.migrated");
119                 if let Err(e) = std::fs::rename(legacy, &done) {
120                     log::warn!("[board] could not set the old sidecar aside: {e}");
121                 }
122                 log::info!("[board] migrated {} item(s) into {}", items.len(), board.path.display());
123             }
124         }
125         (board, items, Vec::new())
126     }
127 
128     /// Re-read the board file (it changed under us: Obsidian, a sync).
129     pub fn read(&mut self) -> Result<(Vec<DesktopItem>, Vec<Edge>), String> {
130         let text = std::fs::read_to_string(&self.path).map_err(|e| e.to_string())?;
131         let canvas = canvas::from_str(&text).map_err(|e| e.to_string())?;
132         let (items, edges) = to_model(&canvas, self.vault.as_deref());
133         self.canvas = canvas;
134         self.seen = mtime(&self.path);
135         Ok((items, edges))
136     }
137 
138     /// True when the file changed since this client last read or wrote it.
139     pub fn changed_on_disk(&self) -> bool {
140         !self.path.as_os_str().is_empty() && mtime(&self.path) != self.seen
141     }
142 
143     pub fn save(&mut self, items: &[DesktopItem], edges: &[Edge]) {
144         if self.path.as_os_str().is_empty() {
145             return; // the unreadable-board case above
146         }
147         apply_model(&mut self.canvas, self.vault.as_deref(), items, edges);
148         let text = canvas::to_string(&self.canvas);
149         if let Some(dir) = self.path.parent() {
150             let _ = std::fs::create_dir_all(dir);
151         }
152         // Write-then-rename beside it: a crash mid-write would leave a
153         // truncated board, which then refuses to load.
154         // Hidden, so vault watchers (which skip dot-files) never see it.
155         let name = self.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
156         let tmp = self.path.with_file_name(format!(".{name}.cce-tmp"));
157         match std::fs::write(&tmp, text).and_then(|_| std::fs::rename(&tmp, &self.path)) {
158             Ok(()) => self.seen = mtime(&self.path),
159             Err(e) => log::error!("[board] could not save {}: {e}", self.path.display()),
160         }
161     }
162 }
163 
164 fn migrate(legacy: &Path) -> Vec<DesktopItem> {
165     let Ok(text) = std::fs::read_to_string(legacy) else { return Vec::new() };
166     let Ok(old) = serde_json::from_str::<Vec<LegacyItem>>(&text) else {
167         log::warn!("[board] {} does not parse; not migrating it", legacy.display());
168         return Vec::new();
169     };
170     old.into_iter()
171         .map(|o| DesktopItem::new(new_id(), kind_for_file(o.path), o.x, o.y, o.w, o.h))
172         .collect()
173 }
174 
175 /// A `file` value as stored, resolved to a path on disk.
176 fn resolve(file: &str, vault: Option<&Path>) -> PathBuf {
177     let p = Path::new(file);
178     match vault {
179         Some(v) if !p.is_absolute() => v.join(p),
180         _ => p.to_path_buf(),
181     }
182 }
183 
184 /// A path as a `file` value: vault-relative inside the vault, else absolute.
185 fn file_value(path: &Path, vault: Option<&Path>) -> String {
186     match vault.and_then(|v| path.strip_prefix(v).ok()) {
187         Some(rel) => rel.to_string_lossy().into_owned(),
188         None => path.to_string_lossy().into_owned(),
189     }
190 }
191 
192 pub fn to_model(canvas: &Canvas, vault: Option<&Path>) -> (Vec<DesktopItem>, Vec<Edge>) {
193     let mut items = Vec::new();
194     for n in canvas.nodes() {
195         let Some(id) = n.str("id") else { continue };
196         let kind = match n.str("type").as_deref() {
197             Some("file") => kind_for_file(resolve(&n.str("file").unwrap_or_default(), vault)),
198             Some("text") => Kind::Text(n.str("text").unwrap_or_default()),
199             Some("link") => Kind::Link(n.str("url").unwrap_or_default()),
200             Some("group") => Kind::Group(n.str("label").unwrap_or_default()),
201             other => Kind::Other(other.unwrap_or("").to_string()),
202         };
203         let num = |k: &str| n.num(k).unwrap_or(0.0);
204         let mut item = DesktopItem::new(id, kind, num("x"), num("y"), num("width").max(1.0), num("height").max(1.0));
205         item.color = n.str("color");
206         items.push(item);
207     }
208     let edges = canvas
209         .edges()
210         .filter_map(|e| {
211             Some(Edge {
212                 id: e.str("id")?,
213                 from: e.str("fromNode")?,
214                 to: e.str("toNode")?,
215                 from_side: e.str("fromSide"),
216                 to_side: e.str("toSide"),
217                 label: e.str("label"),
218                 arrow: e.str("toEnd").as_deref() != Some("none"),
219             })
220         })
221         .collect();
222     (items, edges)
223 }
224 
225 /// Set a string field only when its value differs, so an untouched field
226 /// keeps the exact text (escapes included) it was read as.
227 fn put(o: &mut Object, key: &str, value: &str) {
228     if o.str(key).as_deref() != Some(value) {
229         o.set_str(key, value);
230     }
231 }
232 
233 /// Write the model into the canvas it was read from: nodes in the items'
234 /// order (z-order), each the object it was read as with the modelled
235 /// fields updated, new ones built fresh; edges likewise.
236 pub fn apply_model(canvas: &mut Canvas, vault: Option<&Path>, items: &[DesktopItem], edges: &[Edge]) {
237     let mut old: Vec<Object> = std::mem::take(canvas.objects_mut("nodes"));
238     let mut nodes = Vec::with_capacity(items.len());
239     for item in items {
240         let mut n = match old.iter().position(|o| o.str("id").as_deref() == Some(item.node.as_str())) {
241             Some(i) => old.swap_remove(i),
242             None => {
243                 let mut n = Object::new();
244                 n.set_str("id", &item.node);
245                 n
246             }
247         };
248         // A node that changed type (a text card converted to a note) drops
249         // the fields of the type it was; one that kept its type keeps every
250         // field, known or not.
251         let new_type = match &item.kind {
252             Kind::Image(_) | Kind::Note(_) | Kind::File(_) => Some("file"),
253             Kind::Text(_) => Some("text"),
254             Kind::Link(_) => Some("link"),
255             Kind::Group(_) => Some("group"),
256             Kind::Other(_) => None,
257         };
258         if let Some(t) = new_type {
259             if n.str("type").is_some_and(|old| old != t) {
260                 for key in ["file", "subpath", "text", "url", "label", "background", "backgroundStyle"] {
261                     n.remove(key);
262                 }
263             }
264         }
265         match &item.kind {
266             Kind::Image(p) | Kind::Note(p) | Kind::File(p) => {
267                 put(&mut n, "type", "file");
268                 put(&mut n, "file", &file_value(p, vault));
269             }
270             Kind::Text(t) => {
271                 put(&mut n, "type", "text");
272                 put(&mut n, "text", t);
273             }
274             Kind::Link(u) => {
275                 put(&mut n, "type", "link");
276                 put(&mut n, "url", u);
277             }
278             Kind::Group(l) => {
279                 put(&mut n, "type", "group");
280                 put(&mut n, "label", l);
281             }
282             // Not modelled: written back exactly as read.
283             Kind::Other(_) => {}
284         }
285         // JSON Canvas positions are whole pixels.
286         n.set_num("x", item.x.round());
287         n.set_num("y", item.y.round());
288         n.set_num("width", item.w.round().max(1.0));
289         n.set_num("height", item.h.round().max(1.0));
290         match &item.color {
291             Some(c) => put(&mut n, "color", c),
292             None => n.remove("color"),
293         }
294         nodes.push(n);
295     }
296     *canvas.objects_mut("nodes") = nodes;
297 
298     let mut old: Vec<Object> = std::mem::take(canvas.objects_mut("edges"));
299     let mut out = Vec::with_capacity(edges.len());
300     for e in edges {
301         let mut o = match old.iter().position(|o| o.str("id").as_deref() == Some(e.id.as_str())) {
302             Some(i) => old.swap_remove(i),
303             None => {
304                 let mut o = Object::new();
305                 o.set_str("id", &e.id);
306                 o
307             }
308         };
309         put(&mut o, "fromNode", &e.from);
310         put(&mut o, "toNode", &e.to);
311         for (key, v) in [("fromSide", &e.from_side), ("toSide", &e.to_side), ("label", &e.label)] {
312             match v {
313                 Some(s) => put(&mut o, key, s),
314                 None => o.remove(key),
315             }
316         }
317         match (e.arrow, o.str("toEnd").as_deref()) {
318             (true, Some("none")) => o.remove("toEnd"),
319             (false, _) => put(&mut o, "toEnd", "none"),
320             _ => {}
321         }
322         out.push(o);
323     }
324     *canvas.objects_mut("edges") = out;
325 }
326 
327 #[cfg(test)]
328 mod tests {
329     use super::*;
330 
331     const OBSIDIAN: &str = "{\n\t\"nodes\":[\n\
332         \t\t{\"id\":\"g1\",\"type\":\"group\",\"label\":\"Plans\",\"x\":-100,\"y\":-100,\"width\":900,\"height\":600},\n\
333         \t\t{\"id\":\"n1\",\"type\":\"file\",\"file\":\"notes/Idea.md\",\"x\":0,\"y\":0,\"width\":400,\"height\":300,\"color\":\"4\"},\n\
334         \t\t{\"id\":\"t1\",\"type\":\"text\",\"text\":\"**hi**\",\"x\":450,\"y\":0,\"width\":250,\"height\":60},\n\
335         \t\t{\"id\":\"i1\",\"type\":\"file\",\"file\":\"/home/u/Desktop/cat.png\",\"x\":0,\"y\":400,\"width\":200,\"height\":100},\n\
336         \t\t{\"id\":\"f1\",\"type\":\"future\",\"x\":1,\"y\":2,\"width\":3,\"height\":4,\"what\":[1,2]}\n\
337         \t],\n\t\"edges\":[\n\
338         \t\t{\"id\":\"e1\",\"fromNode\":\"n1\",\"fromSide\":\"right\",\"toNode\":\"t1\",\"toSide\":\"left\",\"label\":\"so\"}\n\
339         \t]\n}";
340 
341     fn vault() -> PathBuf {
342         PathBuf::from("/v")
343     }
344 
345     #[test]
346     fn reads_every_kind_and_edge() {
347         let canvas = canvas::from_str(OBSIDIAN).unwrap();
348         let (items, edges) = to_model(&canvas, Some(&vault()));
349         let kinds: Vec<&Kind> = items.iter().map(|i| &i.kind).collect();
350         assert_eq!(
351             kinds,
352             [
353                 &Kind::Group("Plans".into()),
354                 &Kind::Note("/v/notes/Idea.md".into()),
355                 &Kind::Text("**hi**".into()),
356                 &Kind::Image("/home/u/Desktop/cat.png".into()),
357                 &Kind::Other("future".into()),
358             ]
359         );
360         assert_eq!(items[1].color.as_deref(), Some("4"));
361         assert_eq!(edges[0].label.as_deref(), Some("so"));
362         assert!(edges[0].arrow);
363     }
364 
365     #[test]
366     fn an_untouched_board_saves_byte_for_byte() {
367         let mut canvas = canvas::from_str(OBSIDIAN).unwrap();
368         let (items, edges) = to_model(&canvas, Some(&vault()));
369         apply_model(&mut canvas, Some(&vault()), &items, &edges);
370         assert_eq!(canvas::to_string(&canvas), OBSIDIAN);
371     }
372 
373     #[test]
374     fn edits_reorder_remove_and_add() {
375         let mut canvas = canvas::from_str(OBSIDIAN).unwrap();
376         let (mut items, mut edges) = to_model(&canvas, Some(&vault()));
377         // Raise the note (z-order), move it, drop the text card and its edge,
378         // add a new card for a note outside the vault.
379         let note = items.remove(1);
380         items.push(note);
381         items.last_mut().unwrap().x = 12.4;
382         items.retain(|i| i.node != "t1");
383         edges.clear();
384         items.push(DesktopItem::new("z9".into(), Kind::Note("/elsewhere/X.md".into()), 5.0, 6.0, 400.0, 300.0));
385         apply_model(&mut canvas, Some(&vault()), &items, &edges);
386         let out = canvas::to_string(&canvas);
387         let ids: Vec<String> = canvas.nodes().filter_map(|n| n.str("id")).collect();
388         assert_eq!(ids, ["g1", "i1", "f1", "n1", "z9"]);
389         assert!(out.contains(r#"{"id":"n1","type":"file","file":"notes/Idea.md","x":12,"y":0,"width":400,"height":300,"color":"4"}"#), "{out}");
390         assert!(out.contains(r#""file":"/elsewhere/X.md""#));
391         assert!(out.contains(r#""what":[1,2]"#), "unknown node kept");
392         assert_eq!(canvas.edges().count(), 0);
393     }
394 
395     #[test]
396     fn a_changed_type_drops_the_old_fields() {
397         let mut canvas = canvas::from_str(OBSIDIAN).unwrap();
398         let (mut items, edges) = to_model(&canvas, Some(&vault()));
399         items[2].kind = Kind::Note("/v/hi.md".into());
400         apply_model(&mut canvas, Some(&vault()), &items, &edges);
401         let out = canvas::to_string(&canvas);
402         assert!(out.contains(r#"{"id":"t1","type":"file","x":450,"y":0,"width":250,"height":60,"file":"hi.md"}"#), "{out}");
403     }
404 
405     #[test]
406     fn migrates_the_old_sidecar_once() {
407         let dir = tempfile::tempdir().unwrap();
408         let legacy = dir.path().join("desktop-items.json");
409         std::fs::write(&legacy, r#"[{"path":"/home/u/Desktop/a.jpg","x":-7547.2,"y":-4732.8,"w":512.0,"h":288.0}]"#).unwrap();
410         let board_path = dir.path().join("vault/Desktop.canvas");
411         let (board, items, _) = Board::open_at(board_path.clone(), Some(dir.path().join("vault")), &legacy);
412         assert_eq!(items.len(), 1);
413         assert_eq!(items[0].kind, Kind::Image("/home/u/Desktop/a.jpg".into()));
414         assert!(board_path.exists());
415         assert!(!legacy.exists());
416         assert!(dir.path().join("desktop-items.json.migrated").exists());
417         let text = std::fs::read_to_string(&board_path).unwrap();
418         assert!(text.contains(r#""file":"/home/u/Desktop/a.jpg","x":-7547,"y":-4733,"width":512,"height":288"#), "{text}");
419         // Reopening reads the board, not the (gone) sidecar.
420         let (_, again, _) = Board::open_at(board_path, board.vault.clone(), &legacy);
421         assert_eq!(again.len(), 1);
422     }
423 
424     #[test]
425     fn a_broken_board_is_left_alone() {
426         let dir = tempfile::tempdir().unwrap();
427         let path = dir.path().join("Desktop.canvas");
428         std::fs::write(&path, "{ not json").unwrap();
429         let (mut board, items, _) = Board::open_at(path.clone(), None, &dir.path().join("none.json"));
430         assert!(items.is_empty());
431         board.save(&[], &[]);
432         assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
433     }
434 }