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 }