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 }