notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git
src/attachments.rs (3.3K)
1 //! Where a new attachment goes: Obsidian's "Default location for new
2 //! attachments" (Settings → Files and links), stored as
3 //! `attachmentFolderPath` in `.obsidian/app.json`, so a file another app
4 //! adds to the vault lands where Obsidian would have put it.
5 //!
6 //! The setting's forms, as Obsidian reads them:
7 //!
8 //! | value | folder |
9 //! | --- | --- |
10 //! | missing, `""` or `/` | the vault root (Obsidian's default) |
11 //! | `./` | the folder of the note (or canvas) it is for |
12 //! | `./sub` | `sub` inside that folder |
13 //! | `Some/Folder` | that folder, from the vault root |
14
15 use std::path::{Path, PathBuf};
16
17 /// The vault folder (vault-relative, `""` for the root) that a new
18 /// attachment for the vault file `for_file` goes in.
19 pub fn folder(root: &Path, for_file: &str) -> String {
20 let setting = std::fs::read_to_string(root.join(".obsidian/app.json"))
21 .ok()
22 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
23 .and_then(|v| v.get("attachmentFolderPath")?.as_str().map(str::to_string))
24 .unwrap_or_default();
25 folder_for(&setting, for_file)
26 }
27
28 /// [`folder`] for a setting already read.
29 pub fn folder_for(setting: &str, for_file: &str) -> String {
30 let s = setting.trim();
31 let here = crate::index::parent(for_file);
32 let joined = match s.strip_prefix("./") {
33 Some(sub) => format!("{here}/{sub}"),
34 None if s == "." => here.to_string(),
35 None => s.to_string(),
36 };
37 joined.split('/').filter(|p| !p.is_empty() && *p != ".").collect::<Vec<_>>().join("/")
38 }
39
40 /// An absolute path in `dir` for a file named `name` that does not exist
41 /// yet: `name`, else `name 1`, `name 2`, … before the extension, the way
42 /// Obsidian numbers a clash.
43 pub fn unique_path(dir: &Path, name: &str) -> PathBuf {
44 let first = dir.join(name);
45 if !first.exists() {
46 return first;
47 }
48 let (stem, ext) = match name.rsplit_once('.') {
49 Some((s, e)) if !s.is_empty() => (s, format!(".{e}")),
50 _ => (name, String::new()),
51 };
52 (1..)
53 .map(|n| dir.join(format!("{stem} {n}{ext}")))
54 .find(|p| !p.exists())
55 .expect("an unused name exists")
56 }
57
58 #[cfg(test)]
59 mod tests {
60 use super::*;
61
62 #[test]
63 fn setting_forms() {
64 assert_eq!(folder_for("", "Desktop.canvas"), "");
65 assert_eq!(folder_for("/", "Notes/a.md"), "");
66 assert_eq!(folder_for("./", "Notes/a.md"), "Notes");
67 assert_eq!(folder_for("./", "a.md"), "");
68 assert_eq!(folder_for("./assets", "Notes/a.md"), "Notes/assets");
69 assert_eq!(folder_for("Attachments/", "Notes/a.md"), "Attachments");
70 assert_eq!(folder_for("Media/Img", "a.md"), "Media/Img");
71 }
72
73 #[test]
74 fn reads_app_json_and_numbers_clashes() {
75 let dir = tempfile::tempdir().unwrap();
76 let root = dir.path();
77 assert_eq!(folder(root, "Desktop.canvas"), "");
78 std::fs::create_dir_all(root.join(".obsidian")).unwrap();
79 std::fs::write(root.join(".obsidian/app.json"), r#"{"attachmentFolderPath":"Attachments"}"#).unwrap();
80 assert_eq!(folder(root, "Desktop.canvas"), "Attachments");
81 std::fs::write(root.join("pic.png"), b"x").unwrap();
82 std::fs::write(root.join("pic 1.png"), b"x").unwrap();
83 assert_eq!(unique_path(root, "pic.png"), root.join("pic 2.png"));
84 assert_eq!(unique_path(root, "new.png"), root.join("new.png"));
85 }
86 }