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

commit037fb288bf261bf024993d771a9c04431f0630cc
parent6cad160c4e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 21:31
feat: keep the desktop's images in the vault

A dropped image is saved inside the vault the board lives in -- in the
folder Obsidian puts new attachments (cce_vault::attachments) -- instead
of ~/Desktop, so it syncs with Desktop.canvas and Obsidian on other
devices shows the node rather than a missing file. With no vault it
still goes to the desktop folder.

At startup, images pinned before this (absolute paths outside the vault)
are copied in once and their nodes repointed; the originals stay where
they were.

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

 src/items.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs  | 43 +++++++++++++++++++++++++++++++++++++++----
 2 files changed, 94 insertions(+), 4 deletions(-)

diff --git a/src/items.rs b/src/items.rs
index b33753c..ee573bc 100644
--- a/src/items.rs
+++ b/src/items.rs
@@ -345,6 +345,34 @@ fn decode_base64(s: &str) -> Option<Vec<u8>> {
     Some(out)
 }
 
+/// Where a dropped image is kept. With the board in a vault: inside it, in
+/// the folder Obsidian puts new attachments for `board_file` in
+/// (`cce_vault::attachments`), so the picture syncs with the board and
+/// Obsidian shows the node instead of a missing file. Without a vault: the
+/// desktop folder, as before.
+pub fn save_image(bytes: &[u8], name: &str, vault: Option<&Path>, board_file: &str) -> std::io::Result<PathBuf> {
+    let Some(vault) = vault else { return save_to_desktop(bytes, name) };
+    let dir = vault.join(cce_vault::attachments::folder(vault, board_file));
+    std::fs::create_dir_all(&dir)?;
+    let path = cce_vault::attachments::unique_path(&dir, name);
+    std::fs::write(&path, bytes)?;
+    Ok(path)
+}
+
+/// Copy an image pinned from outside the vault into it (see [`save_image`]),
+/// leaving the original where it was. The new path, or `None` when it is
+/// already inside, or cannot be read.
+pub fn adopt_into_vault(path: &Path, vault: &Path, board_file: &str) -> Option<PathBuf> {
+    if path.starts_with(vault) {
+        return None;
+    }
+    let bytes = std::fs::read(path).ok()?;
+    let name = path.file_name()?.to_string_lossy().into_owned();
+    save_image(&bytes, &name, Some(vault), board_file)
+        .map_err(|e| log::warn!("[board] copying {} into the vault: {e}", path.display()))
+        .ok()
+}
+
 /// Save bytes into the desktop folder under a non-colliding name.
 pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
     let dir = desktop_dir();
@@ -464,6 +492,33 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
 mod tests {
     use super::*;
 
+    #[test]
+    fn dropped_images_land_in_the_vault_attachment_folder() {
+        let vault = tempfile::tempdir().unwrap();
+        let v = vault.path();
+        let p = save_image(b"png", "pic.png", Some(v), "Desktop.canvas").unwrap();
+        assert_eq!(p, v.join("pic.png"));
+        // A clash is numbered the way Obsidian numbers it.
+        assert_eq!(save_image(b"png", "pic.png", Some(v), "Desktop.canvas").unwrap(), v.join("pic 1.png"));
+        // Obsidian's attachment folder setting is followed.
+        std::fs::create_dir_all(v.join(".obsidian")).unwrap();
+        std::fs::write(v.join(".obsidian/app.json"), r#"{"attachmentFolderPath":"Attachments"}"#).unwrap();
+        assert_eq!(save_image(b"png", "pic.png", Some(v), "Desktop.canvas").unwrap(), v.join("Attachments/pic.png"));
+    }
+
+    #[test]
+    fn outside_images_are_copied_in_and_inside_ones_left() {
+        let (vault, outside) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
+        let src = outside.path().join("shot.jpg");
+        std::fs::write(&src, b"jpg").unwrap();
+        let new = adopt_into_vault(&src, vault.path(), "Desktop.canvas").unwrap();
+        assert_eq!(new, vault.path().join("shot.jpg"));
+        assert_eq!(std::fs::read(&new).unwrap(), b"jpg");
+        assert!(src.exists(), "the original stays");
+        assert_eq!(adopt_into_vault(&new, vault.path(), "Desktop.canvas"), None);
+        assert_eq!(adopt_into_vault(&outside.path().join("gone.jpg"), vault.path(), "Desktop.canvas"), None);
+    }
+
     #[test]
     fn uri_list_takes_the_first_real_line() {
         let data = b"# comment\r\nhttps://example.com/cat.png\r\nhttps://other\r\n";
diff --git a/src/main.rs b/src/main.rs
index e695e65..ca07526 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -983,6 +983,38 @@ impl GridApp {
         None
     }
 
+    /// The board file's vault path (`Desktop.canvas`), what an attachment
+    /// for it is placed relative to.
+    fn board_file(&self) -> String {
+        let vault = self.board.vault.as_deref();
+        vault
+            .and_then(|v| self.board.path.strip_prefix(v).ok())
+            .map(|p| p.to_string_lossy().into_owned())
+            .unwrap_or_else(|| "Desktop.canvas".to_string())
+    }
+
+    /// Images pinned before the board kept them in the vault point outside
+    /// it (the desktop folder), so they reach no other device and Obsidian
+    /// shows them missing. Copy each in — the original stays put — and
+    /// repoint its node. Runs at startup; a no-op once all are inside.
+    fn adopt_outside_images(&mut self) {
+        let Some(vault) = self.board.vault.clone() else { return };
+        let board_file = self.board_file();
+        let mut moved = 0;
+        for (item, _) in &mut self.items {
+            if let Kind::Image(p) = &item.kind {
+                if let Some(new) = items::adopt_into_vault(p, &vault, &board_file) {
+                    log::info!("[board] copied {} into the vault as {}", p.display(), new.display());
+                    item.kind = Kind::Image(new);
+                    moved += 1;
+                }
+            }
+        }
+        if moved > 0 {
+            self.save_items();
+        }
+    }
+
     fn save_items(&mut self) {
         let model: Vec<items::DesktopItem> = self.items.iter().map(|(i, _)| i.clone()).collect();
         self.board.save(&model, &self.edges);
@@ -1240,6 +1272,7 @@ impl Application for GridApp {
             app.assign_id(&mut item);
             app.items.push((item, None));
         }
+        app.adopt_outside_images();
         app.report_items();
         app
     }
@@ -1450,6 +1483,8 @@ impl Application for GridApp {
         let (cell_w, cell_h) = (st.cell_w.max(16.0), st.cell_h.max(16.0));
         let sender = self.sender.clone();
         let mime = mime.to_string();
+        let vault = self.board.vault.clone();
+        let board_file = self.board_file();
         std::thread::spawn(move || {
             let (bytes, name) = match items::fetch(payload) {
                 Ok(v) => v,
@@ -1471,12 +1506,12 @@ impl Application for GridApp {
                 ));
                 return;
             };
-            // Save even though it is already decoded: the user asked for the
-            // file on their desktop, not just a picture on the canvas.
-            let path = match items::save_to_desktop(&bytes, &name) {
+            // Save even though it is already decoded: the board points at a
+            // file, and in a vault that file is what syncs with it.
+            let path = match items::save_image(&bytes, &name, vault.as_deref(), &board_file) {
                 Ok(p) => p,
                 Err(e) => {
-                    items::report_failure(&format!("could not save it to the desktop: {e}"));
+                    items::report_failure(&format!("could not save the image: {e}"));
                     return;
                 }
             };