git.lucas.co / cce-vault
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 }