git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

src/paste.rs (8K)

  1 //! Pasting and dropping images, as Obsidian does: a picture on
  2 //! the clipboard — a screenshot, an image copied in a browser — or image
  3 //! files copied in a file manager become files in the vault's attachment
  4 //! folder (`cce_vault::attachments`, Obsidian's own setting) and `![[…]]`
  5 //! embeds at the caret. Anything else pastes as text, through the editor.
  6 //!
  7 //! The clipboard is read with `wl-paste`, as cce-ui's text paste reads it.
  8 
  9 use std::path::{Path, PathBuf};
 10 use std::process::Command;
 11 
 12 /// Image types, best first, and the extension each is saved with.
 13 const IMAGE_TYPES: [(&str, &str); 5] =
 14     [("image/png", "png"), ("image/jpeg", "jpg"), ("image/webp", "webp"), ("image/gif", "gif"), ("image/bmp", "bmp")];
 15 
 16 #[derive(Debug, PartialEq)]
 17 pub enum Clip {
 18     /// Picture bytes, and the extension they are saved with.
 19     Image { bytes: Vec<u8>, ext: &'static str },
 20     /// Image files copied in a file manager.
 21     Files(Vec<PathBuf>),
 22 }
 23 
 24 /// What a drop takes, best first: a browser's dragged picture as pixels,
 25 /// else the file list a file manager's drag carries.
 26 pub const DROP_MIMES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "text/uri-list"];
 27 
 28 /// What a drop of `data` as `mime` attaches, if anything.
 29 pub fn from_drop(mime: &str, data: &[u8]) -> Option<Clip> {
 30     if let Some((_, ext)) = IMAGE_TYPES.iter().find(|(m, _)| *m == mime) {
 31         return (!data.is_empty()).then(|| Clip::Image { bytes: data.to_vec(), ext });
 32     }
 33     if mime == "text/uri-list" {
 34         let files = image_files(&String::from_utf8_lossy(data));
 35         return (!files.is_empty()).then_some(Clip::Files(files));
 36     }
 37     None
 38 }
 39 
 40 /// What on the clipboard a paste should turn into attachments, if anything.
 41 pub fn read() -> Option<Clip> {
 42     let types = wl_paste(&["--list-types"])?;
 43     let types: Vec<&str> = std::str::from_utf8(&types).ok()?.lines().map(str::trim).collect();
 44     log::debug!("paste: clipboard offers {types:?}");
 45     // Copied files first: a file manager offers their paths as text/plain
 46     // too. A list naming no local image (a browser's copied link) falls
 47     // through to the text paste.
 48     if types.contains(&"text/uri-list") {
 49         let list = wl_paste(&["--type", "text/uri-list"])?;
 50         let files = image_files(&String::from_utf8_lossy(&list));
 51         if !files.is_empty() {
 52             return Some(Clip::Files(files));
 53         }
 54     }
 55     let (mime, ext) = image_offer(&types)?;
 56     let bytes = wl_paste(&["--type", mime])?;
 57     (!bytes.is_empty()).then_some(Clip::Image { bytes, ext })
 58 }
 59 
 60 /// The picture type to take, if any. Only when no plain text is offered
 61 /// too: copying text from a browser or an office app often carries an
 62 /// image rendering of it as well, and that paste must stay text. A copied
 63 /// image (browser, screenshot tool) offers no text/plain.
 64 fn image_offer(types: &[&str]) -> Option<(&'static str, &'static str)> {
 65     if types.iter().any(|t| t.starts_with("text/plain")) {
 66         return None;
 67     }
 68     IMAGE_TYPES.iter().copied().find(|(m, _)| types.contains(m))
 69 }
 70 
 71 /// The local image files a `text/uri-list` names (comments and other files
 72 /// skipped).
 73 fn image_files(list: &str) -> Vec<PathBuf> {
 74     list.lines()
 75         .map(str::trim)
 76         .filter(|l| !l.starts_with('#'))
 77         .filter_map(|l| l.strip_prefix("file://"))
 78         // `file://host/path`: only the local form, with an empty host.
 79         .filter(|p| p.starts_with('/'))
 80         .map(|p| PathBuf::from(percent_encoding::percent_decode_str(p).decode_utf8_lossy().into_owned()))
 81         .filter(|p| cce_vault::markdown::is_image(&p.to_string_lossy()) && p.is_file())
 82         .collect()
 83 }
 84 
 85 fn wl_paste(args: &[&str]) -> Option<Vec<u8>> {
 86     let out = Command::new("wl-paste").args(args).output().ok()?;
 87     out.status.success().then_some(out.stdout)
 88 }
 89 
 90 /// Write `clip` into `dir` (made if missing): a picture as `Pasted image
 91 /// <timestamp>.<ext>`, Obsidian's name; files under their own names. Names
 92 /// that clash are numbered. The paths written.
 93 pub fn store(clip: Clip, dir: &Path, now: chrono::NaiveDateTime) -> std::io::Result<Vec<PathBuf>> {
 94     std::fs::create_dir_all(dir)?;
 95     match clip {
 96         Clip::Image { bytes, ext } => {
 97             let name = format!("Pasted image {}.{ext}", now.format("%Y%m%d%H%M%S"));
 98             let path = cce_vault::attachments::unique_path(dir, &name);
 99             std::fs::write(&path, bytes)?;
100             Ok(vec![path])
101         }
102         Clip::Files(files) => {
103             let mut out = Vec::new();
104             for f in files {
105                 let Some(name) = f.file_name() else { continue };
106                 let path = cce_vault::attachments::unique_path(dir, &name.to_string_lossy());
107                 std::fs::copy(&f, &path)?;
108                 out.push(path);
109             }
110             Ok(out)
111         }
112     }
113 }
114 
115 /// The text a paste of `embeds` (`![[…]]` lines) inserts between `before`
116 /// and `after` (the caret line's text on either side of the selection):
117 /// each embed on its own line, since only a line that is one embed shows
118 /// as the picture. Returns (text, its length up to where the caret goes).
119 pub fn insertion(embeds: &[String], before: &str, after: &str) -> (String, usize) {
120     let mut text = String::new();
121     if !before.trim().is_empty() {
122         text.push('\n');
123     }
124     text.push_str(&embeds.join("\n"));
125     let caret = text.len();
126     if !after.trim().is_empty() {
127         text.push('\n');
128     }
129     (text, caret)
130 }
131 
132 #[cfg(test)]
133 mod tests {
134     use super::*;
135 
136     #[test]
137     fn text_wins_over_an_image_rendering() {
138         assert_eq!(image_offer(&["image/png", "text/html"]), Some(("image/png", "png")));
139         assert_eq!(image_offer(&["image/jpeg", "image/png"]), Some(("image/png", "png")));
140         assert_eq!(image_offer(&["text/html", "text/plain;charset=utf-8", "image/png"]), None);
141         assert_eq!(image_offer(&["text/uri-list"]), None);
142         assert_eq!(image_offer(&[]), None);
143     }
144 
145     #[test]
146     fn uri_lists_keep_local_images() {
147         let dir = tempfile::tempdir().unwrap();
148         let a = dir.path().join("my pic.png");
149         std::fs::write(&a, b"x").unwrap();
150         std::fs::write(dir.path().join("doc.pdf"), b"x").unwrap();
151         let list = format!(
152             "# copied\nfile://{}\nfile://{}/doc.pdf\nfile://{}/gone.png\nhttps://x.y/z.png\n",
153             a.display().to_string().replace(' ', "%20"),
154             dir.path().display(),
155             dir.path().display()
156         );
157         assert_eq!(image_files(&list), vec![a]);
158     }
159 
160     #[test]
161     fn drops_take_pixels_or_image_files() {
162         assert_eq!(from_drop("image/jpeg", b"jpg"), Some(Clip::Image { bytes: b"jpg".to_vec(), ext: "jpg" }));
163         assert_eq!(from_drop("image/png", b""), None);
164         assert_eq!(from_drop("text/uri-list", b"https://x.y/a.png\r\n"), None);
165         let dir = tempfile::tempdir().unwrap();
166         let a = dir.path().join("a.webp");
167         std::fs::write(&a, b"x").unwrap();
168         let list = format!("file://{}\r\n", a.display());
169         assert_eq!(from_drop("text/uri-list", list.as_bytes()), Some(Clip::Files(vec![a])));
170         assert_eq!(from_drop("text/html", b"<img>"), None);
171     }
172 
173     #[test]
174     fn stores_with_obsidian_names() {
175         let dir = tempfile::tempdir().unwrap();
176         let now = chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap().and_hms_opt(21, 35, 12).unwrap();
177         let p = store(Clip::Image { bytes: b"png".to_vec(), ext: "png" }, &dir.path().join("att"), now).unwrap();
178         assert_eq!(p, vec![dir.path().join("att/Pasted image 20261001213512.png")]);
179         let p = store(Clip::Image { bytes: b"png".to_vec(), ext: "png" }, &dir.path().join("att"), now).unwrap();
180         assert_eq!(p, vec![dir.path().join("att/Pasted image 20261001213512 1.png")]);
181     }
182 
183     #[test]
184     fn embeds_go_on_their_own_lines() {
185         let e = vec!["![[a.png]]".to_string()];
186         assert_eq!(insertion(&e, "", ""), ("![[a.png]]".into(), 10));
187         assert_eq!(insertion(&e, "text ", " more"), ("\n![[a.png]]\n".into(), 11));
188         let two = vec!["![[a.png]]".to_string(), "![[b.png]]".to_string()];
189         assert_eq!(insertion(&two, "", "").0, "![[a.png]]\n![[b.png]]");
190     }
191 }