basalt_core/obsidian/vault.rs
1//! This module provides functionality operating with Obsidian vaults.
2use std::{
3 fs, io,
4 ops::ControlFlow,
5 path::{self, Path, PathBuf},
6 result,
7};
8
9use serde::{Deserialize, Deserializer};
10
11use crate::obsidian::{directory::Directory, vault_entry::VaultEntry, Error, Note};
12
13/// Represents a single Obsidian vault.
14///
15/// A vault is a folder containing notes and other metadata.
16#[derive(Debug, Clone, Default, PartialEq)]
17pub struct Vault {
18 /// The name of the vault, inferred from its directory name.
19 pub name: String,
20
21 /// Filesystem path to the vault's directory.
22 pub path: PathBuf,
23
24 /// Whether the vault is marked 'open' by Obsidian.
25 pub open: bool,
26
27 /// Timestamp of last update or creation.
28 pub ts: u64,
29}
30
31fn basename(path: &Path, extension: Option<&str>) -> result::Result<String, Error> {
32 match extension {
33 Some(_) => path.file_stem(),
34 None => path.file_name(),
35 }
36 .and_then(|os_str| os_str.to_str().map(|str| str.to_string()))
37 .ok_or_else(|| Error::InvalidPathName(path.to_path_buf()))
38}
39
40/// Creates link replacement patterns for updating links when renaming a note or directory.
41///
42/// Returns a vector of (old_pattern, new_pattern) tuples for:
43/// - Simple wikilinks: `[[note]]`, `[[note|`, `[[note#`
44fn wiki_link_replacements(old_name: &str, new_name: &str) -> [(String, String); 3] {
45 [
46 (format!("[[{old_name}]]"), format!("[[{new_name}]]")),
47 (format!("[[{old_name}|"), format!("[[{new_name}|")),
48 (format!("[[{old_name}#"), format!("[[{new_name}#")),
49 ]
50}
51
52/// Replaces all occurrences of patterns in content.
53fn replace_content(content: &str, replacements: &[(String, String)]) -> String {
54 replacements
55 .iter()
56 .fold(content.to_string(), |content, (old, new)| {
57 content.replace(old, new)
58 })
59}
60
61/// Updates wiki-links for the given path across all notes in the vault with the new path.
62///
63/// Handles simple links (`[[name]]`), links with aliases (`[[name|alias]]`), and links with
64/// headings (`[[name#heading]]`). No updates are performed if the name is unchanged.
65///
66/// # Examples
67///
68/// ```
69/// # use std::fs;
70/// # use tempfile::tempdir;
71/// # use basalt_core::obsidian::{self, Vault, Note, Error};
72/// #
73/// # let tmp_dir = tempdir()?;
74/// let vault = Vault { path: tmp_dir.path().to_path_buf(), ..Default::default() };
75/// let note_a = obsidian::vault::create_note(&vault.path, "Note A")?;
76/// let note_b = obsidian::vault::create_note(&vault.path, "Note B")?;
77/// fs::write(note_a.path(), "A link to [[Note B]]")?;
78/// fs::write(note_b.path(), "A link to [[Note A]] and link to self [[Note B]]")?;
79/// # assert_eq!(fs::read_to_string(note_a.path())?, "A link to [[Note B]]");
80/// # assert_eq!(fs::read_to_string(note_b.path())?, "A link to [[Note A]] and link to self [[Note B]]");
81///
82/// let old_path = note_b.path();
83/// let note_b = obsidian::vault::rename_note(note_b.clone(), "Renamed B")?;
84/// obsidian::vault::update_wiki_links(&vault, old_path, note_b.path())?;
85///
86/// let content_a = fs::read_to_string(note_a.path());
87/// let content_b = fs::read_to_string(note_b.path());
88/// assert_eq!(fs::read_to_string(note_a.path())?, "A link to [[Renamed B]]");
89/// assert_eq!(fs::read_to_string(note_b.path())?, "A link to [[Note A]] and link to self [[Renamed B]]");
90/// # Ok::<(), Error>(())
91/// ```
92pub fn update_wiki_links(
93 vault: &Vault,
94 old_path: &Path,
95 new_path: &Path,
96) -> result::Result<(), Error> {
97 let old_ext = old_path.extension().and_then(|ext| ext.to_str());
98 let old_name = basename(old_path, old_ext)?;
99
100 let new_ext = new_path.extension().and_then(|ext| ext.to_str());
101 let new_name = basename(new_path, new_ext)?;
102
103 if old_name == new_name {
104 return Ok(());
105 }
106
107 let replacements = wiki_link_replacements(&old_name, &new_name);
108
109 fn replace_wiki_link<'a>(
110 replacements: &'a [(String, String)],
111 ) -> impl Fn(Note) -> io::Result<()> + 'a {
112 |note| {
113 let content = fs::read_to_string(note.path())?;
114 let updated_content = replace_content(&content, replacements);
115
116 if content != updated_content {
117 fs::write(note.path(), updated_content)?;
118 }
119
120 Ok(())
121 }
122 }
123
124 fn entry_to_note(entry: VaultEntry) -> Vec<Note> {
125 match entry {
126 VaultEntry::File(note) => vec![note],
127 VaultEntry::Directory { entries, .. } => {
128 entries.into_iter().flat_map(entry_to_note).collect()
129 }
130 }
131 }
132
133 vault
134 .entries()
135 .into_iter()
136 .flat_map(entry_to_note)
137 .try_for_each(replace_wiki_link(&replacements))?;
138
139 Ok(())
140}
141
142/// Rename directory with the given name.
143///
144/// # Examples
145///
146/// ```
147/// # use std::fs;
148/// # use tempfile::tempdir;
149/// # use basalt_core::obsidian::{self, Vault, Note, Error};
150/// #
151/// # let tmp_dir = tempdir()?;
152/// # let tmp_path = tmp_dir.path();
153/// #
154/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
155/// let directory = obsidian::vault::create_dir(&vault.path, "Arbitrary Name")?;
156///
157/// let directory = obsidian::vault::rename_dir(directory, "/New Name.md")?;
158/// assert_eq!(directory.name(), "New Name.md");
159/// assert_eq!(directory.path(), tmp_path.join("New Name.md"));
160/// assert_eq!(fs::exists(directory.path())?, true);
161/// assert_eq!(directory.path().is_dir(), true);
162///
163/// let directory = obsidian::vault::rename_dir(directory, "Renamed")?;
164/// assert_eq!(directory.name(), "Renamed");
165/// assert_eq!(directory.path(), tmp_path.join("Renamed"));
166/// assert_eq!(fs::exists(directory.path())?, true);
167/// # Ok::<(), Error>(())
168/// ```
169pub fn rename_dir(directory: Directory, new_name: &str) -> result::Result<Directory, Error> {
170 if new_name.is_empty() {
171 return Err(Error::EmptyFileName(PathBuf::default()));
172 }
173
174 let new_name = new_name.trim_start_matches(path::MAIN_SEPARATOR);
175
176 let path = directory.path();
177 let parent = path
178 .parent()
179 .ok_or(Error::EmptyFileName(path.to_path_buf()))?;
180
181 let new_path = parent.join(new_name);
182
183 if fs::exists(&new_path)? {
184 return Err(Error::Io(std::io::ErrorKind::AlreadyExists.into()));
185 }
186
187 // FIXME: After checking for invalid filenames
188 if let Some(path) = new_path.parent() {
189 fs::create_dir_all(path)?
190 }
191
192 fs::rename(path, &new_path)?;
193
194 Directory::try_from((new_name, new_path))
195}
196
197/// Rename note with the given name.
198///
199/// # Examples
200///
201/// ```
202/// # use std::fs;
203/// # use tempfile::tempdir;
204/// # use basalt_core::obsidian::{self, Vault, Note, Error};
205/// #
206/// # let tmp_dir = tempdir()?;
207/// # let tmp_path = tmp_dir.path();
208/// #
209/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
210/// let note = obsidian::vault::create_note(&vault.path, "Arbitrary Name")?;
211///
212/// let note = obsidian::vault::rename_note(note, "New Name.md")?;
213/// assert_eq!(note.name(), "New Name");
214/// assert_eq!(note.path(), tmp_path.join("New Name.md"));
215/// assert_eq!(fs::exists(note.path())?, true);
216///
217/// let note = obsidian::vault::rename_note(note, "Renamed")?;
218/// assert_eq!(note.name(), "Renamed");
219/// assert_eq!(note.path(), tmp_path.join("Renamed.md"));
220/// assert_eq!(fs::exists(note.path())?, true);
221/// # Ok::<(), Error>(())
222/// ```
223pub fn rename_note(note: Note, new_name: &str) -> result::Result<Note, Error> {
224 if new_name.is_empty() {
225 return Err(Error::EmptyFileName(PathBuf::default()));
226 }
227
228 let path = note.path();
229 let parent = path
230 .parent()
231 .ok_or(Error::EmptyFileName(path.to_path_buf()))?;
232
233 let new_name = new_name
234 .trim_start_matches(path::MAIN_SEPARATOR)
235 .trim_end_matches(".md");
236 let new_path = parent.join(new_name).with_extension("md");
237
238 if fs::exists(&new_path)? {
239 return Err(Error::Io(std::io::ErrorKind::AlreadyExists.into()));
240 }
241
242 // FIXME: After checking for invalid filenames
243 if let Some(path) = new_path.parent() {
244 fs::create_dir_all(path)?
245 }
246
247 fs::rename(path, &new_path)?;
248
249 Note::try_from((new_name, new_path))
250}
251
252/// Moves the note to the given directory.
253///
254/// # Examples
255///
256/// ```
257/// # use std::fs;
258/// # use tempfile::tempdir;
259/// # use basalt_core::obsidian::{self, Vault, Note, Error};
260/// #
261/// # let tmp_dir = tempdir()?;
262/// # let tmp_path = tmp_dir.path();
263/// #
264/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
265/// let note = obsidian::vault::create_note(&vault.path, "/notes/Arbitrary Name")?;
266/// let dir = obsidian::vault::create_dir(&vault.path, "/archive")?;
267/// let note = obsidian::vault::move_note_to(note, dir)?;
268///
269/// assert_eq!(note.name(), "Arbitrary Name");
270/// assert_eq!(note.path(), tmp_path.join("archive/Arbitrary Name.md"));
271/// assert_eq!(fs::exists(note.path())?, true);
272/// # Ok::<(), Error>(())
273/// ```
274pub fn move_note_to(note: Note, directory: Directory) -> result::Result<Note, Error> {
275 let name = basename(note.path(), None)?;
276
277 let new_path = directory.path().join(name);
278 if fs::exists(&new_path)? {
279 return Err(Error::Io(std::io::ErrorKind::AlreadyExists.into()));
280 }
281
282 fs::rename(note.path(), &new_path)?;
283
284 Note::try_from((note.name(), new_path))
285}
286
287/// Moves directory to the given directory.
288///
289/// # Examples
290///
291/// ```
292/// # use std::fs;
293/// # use tempfile::tempdir;
294/// # use basalt_core::obsidian::{self, Vault, Note, Error};
295/// #
296/// # let tmp_dir = tempdir()?;
297/// # let tmp_path = tmp_dir.path();
298/// #
299/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
300/// let dir_a = obsidian::vault::create_dir(&vault.path, "/notes")?;
301/// let dir_b = obsidian::vault::create_dir(&vault.path, "/archive")?;
302/// let dir = obsidian::vault::move_dir_to(dir_a, dir_b)?;
303///
304/// assert_eq!(dir.name(), "notes");
305/// assert_eq!(dir.path(), tmp_path.join("archive/notes"));
306/// assert_eq!(fs::exists(dir.path())?, true);
307/// # Ok::<(), Error>(())
308/// ```
309pub fn move_dir_to(from: Directory, to: Directory) -> result::Result<Directory, Error> {
310 let name = basename(from.path(), None)?;
311
312 let new_path = to.path().join(&name);
313 if fs::exists(&new_path)? {
314 return Err(Error::Io(std::io::ErrorKind::AlreadyExists.into()));
315 }
316
317 fs::rename(from.path(), &new_path)?;
318
319 Directory::try_from((from.name(), new_path))
320}
321
322/// Creates a new empty directory with the provided name.
323///
324/// If a directory with the given name already exists, a numbered suffix will be appended
325/// (e.g., "Dir 1", "Dir 2", etc.) to find an available name.
326///
327/// # Errors
328///
329/// Returns an error if:
330/// - I/O operations fail (directory creation, path checks)
331/// - No available name is found after 999 attempts ([`Error::MaxAttemptsExceeded`])
332///
333/// # Examples
334///
335/// ```
336/// # use std::fs;
337/// # use tempfile::tempdir;
338/// # use basalt_core::obsidian::{self, Vault, Note, Error};
339/// #
340/// # let tmp_dir = tempdir()?;
341///
342/// let vault = Vault { path: tmp_dir.path().to_path_buf(), ..Default::default() };
343/// let dir = obsidian::vault::create_dir(&vault.path, "/sub-dir/Arbitrary.Name")?;
344/// # assert_eq!(dir.name(), "Arbitrary.Name");
345/// # assert_eq!(dir.path().is_dir(), true);
346/// # assert_eq!(fs::exists(dir.path())?, true);
347/// # Ok::<(), Error>(())
348/// ```
349pub fn create_dir<T: AsRef<Path>>(path: T, name: &str) -> result::Result<Directory, Error> {
350 let (name, path) = find_available_path_name(path, name, None)?;
351 fs::create_dir_all(&path)?;
352 Directory::try_from((name, path))
353}
354
355/// Creates a new empty directory with name "Untitled" or "Untitled {n}".
356///
357/// This is a convenience method that calls [`Vault::create_dir`] with "Untitled" as the name.
358///
359/// # Errors
360///
361/// Returns an error if:
362/// - I/O operations fail (file writing or path checks)
363/// - No available name is found after 999 attempts ([`Error::MaxAttemptsExceeded`])
364///
365/// # Examples
366///
367/// ```
368/// # use std::{fs, result};
369/// # use tempfile::tempdir;
370/// # use basalt_core::obsidian::{self, Vault, Note, Error};
371/// #
372/// # let tmp_dir = tempdir()?;
373/// # let tmp_path = tmp_dir.path();
374/// #
375/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
376/// let dir = obsidian::vault::create_untitled_dir(&vault.path)?;
377///
378/// assert_eq!(dir.name(), "Untitled");
379/// assert_eq!(fs::exists(dir.path())?, true);
380/// assert_eq!(dir.path().is_dir(), true);
381/// #
382/// # (1..=100).try_for_each(|n| -> result::Result<(), Error> {
383/// # let dir = obsidian::vault::create_untitled_dir(&vault.path)?;
384/// # assert_eq!(dir.name(), format!("Untitled {n}"));
385/// # assert_eq!(fs::exists(dir.path())?, true);
386/// # assert_eq!(dir.path().is_dir(), true);
387/// # Ok(())
388/// # })?;
389/// # Ok::<(), Error>(())
390/// ```
391/// FIXME: Support directory parameter to add folders automatically to sub paths
392pub fn create_untitled_dir<T: AsRef<Path>>(path: T) -> result::Result<Directory, Error> {
393 create_dir(path, "Untitled")
394}
395
396/// Creates a new empty note with the provided name.
397///
398/// If a note with the given name already exists, a numbered suffix will be appended
399/// (e.g., "Note 1", "Note 2", etc.) to find an available name.
400///
401/// # Errors
402///
403/// Returns an error if:
404/// - I/O operations fail (directory creation, file writing, or path checks)
405/// - No available name is found after 999 attempts ([`Error::MaxAttemptsExceeded`])
406///
407/// # Examples
408///
409/// ```
410/// # use std::fs;
411/// # use tempfile::tempdir;
412/// # use basalt_core::obsidian::{self, Vault, Note, Error};
413/// #
414/// # let tmp_dir = tempdir()?;
415/// # let tmp_path = tmp_dir.path();
416/// #
417/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
418/// let note = obsidian::vault::create_note(vault.path, "/notes/Arbitrary Name")?;
419/// assert_eq!(note.name(), "Arbitrary Name");
420/// assert_eq!(note.path(), tmp_path.join("notes/Arbitrary Name.md"));
421/// assert_eq!(fs::exists(note.path())?, true);
422/// # Ok::<(), Error>(())
423/// ```
424pub fn create_note<T: AsRef<Path>>(path: T, name: &str) -> result::Result<Note, Error> {
425 let name = name.trim_start_matches(path::MAIN_SEPARATOR);
426 let path = path.as_ref();
427
428 let base_path = path.join(name).with_extension("md");
429 if let Some(parent_dir) = base_path.parent() {
430 // Create necessary directory structures if we pass dir separated name like
431 // /vault/notes/sub-notes/name.md
432 fs::create_dir_all(parent_dir)?;
433 }
434
435 let (name, path) = find_available_path_name(path, name, Some("md"))?;
436
437 fs::write(&path, "")?;
438
439 Note::try_from((name, path))
440}
441
442/// Creates a new empty note with name "Untitled" or "Untitled {n}".
443///
444/// This is a convenience method that calls [`Vault::create_note`] with "Untitled" as the name.
445///
446/// # Errors
447///
448/// Returns an error if:
449/// - I/O operations fail (file writing or path checks)
450/// - No available name is found after 999 attempts ([`Error::MaxAttemptsExceeded`])
451///
452/// # Examples
453///
454/// ```
455/// # use std::{fs, result};
456/// # use tempfile::tempdir;
457/// # use basalt_core::obsidian::{self, Vault, Note, Error};
458/// #
459/// # let tmp_dir = tempdir()?;
460/// # let tmp_path = tmp_dir.path();
461/// #
462/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
463/// let note = obsidian::vault::create_untitled_note(&vault.path)?;
464/// assert_eq!(note.name(), "Untitled");
465/// assert_eq!(fs::exists(note.path())?, true);
466/// #
467/// # (1..=100).try_for_each(|n| -> result::Result<(), Error> {
468/// # let note = obsidian::vault::create_untitled_note(&vault.path)?;
469/// # assert_eq!(note.name(), format!("Untitled {n}"));
470/// # assert_eq!(fs::exists(note.path())?, true);
471/// # Ok(())
472/// # })?;
473/// # Ok::<(), Error>(())
474/// ```
475pub fn create_untitled_note<T: AsRef<Path>>(path: T) -> result::Result<Note, Error> {
476 create_note(path, "Untitled")
477}
478
479/// Find available path name by incrementing number suffix at the end.
480///
481/// Increments until we find a 'free' name e.g. if "Untitled 1" exists we will
482/// try next "Untitled 2", and then "Untitled 3" and so on.
483///
484/// # Errors
485///
486/// Returns [`Error::MaxAttemptsExceeded`] if no available name is found after 999 attempts.
487///
488/// # Examples
489///
490/// ## Markdown filename
491/// ```
492/// # use std::fs;
493/// # use tempfile::tempdir;
494/// # use basalt_core::obsidian::{self, Vault, Note, Error};
495/// #
496/// # let tmp_dir = tempdir()?;
497/// # let tmp_path = tmp_dir.path();
498/// #
499/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
500/// let note_name = "Arbitrary Name";
501/// # fs::write(tmp_path.join(note_name).with_extension("md"), "")?;
502///
503/// let (name, path) = obsidian::vault::find_available_path_name(&vault.path, note_name, Some("md"))?;
504/// assert_eq!(&name, "Arbitrary Name 1");
505/// assert_eq!(fs::exists(&path)?, false);
506/// # Ok::<(), Error>(())
507/// ```
508///
509/// ## Directory name
510/// ```
511/// # use std::fs;
512/// # use tempfile::tempdir;
513/// # use basalt_core::obsidian::{self, Vault, Note, Error};
514/// #
515/// # let tmp_dir = tempdir()?;
516/// # let tmp_path = tmp_dir.path();
517/// #
518/// let vault = Vault { path: tmp_path.to_path_buf(), ..Default::default() };
519/// let dir_name = "Arbitrary.Dir";
520/// # fs::create_dir_all(tmp_path.join(dir_name))?;
521///
522/// let (name, path) = obsidian::vault::find_available_path_name(&vault.path, dir_name, None)?;
523/// assert_eq!(&name, "Arbitrary.Dir 1");
524/// assert_eq!(fs::exists(&path)?, false);
525/// # Ok::<(), Error>(())
526/// ```
527pub fn find_available_path_name<T: AsRef<Path>>(
528 path: T,
529 name: &str,
530 extension: Option<&str>,
531) -> result::Result<(String, PathBuf), Error> {
532 let name = name.trim_start_matches(path::MAIN_SEPARATOR);
533 let path = path.as_ref();
534
535 let name_to_path = |name: &str| match extension {
536 Some(ext) => path.join(name).with_extension(ext),
537 None => path.join(name),
538 };
539
540 let path = name_to_path(name);
541 if !fs::exists(&path)? {
542 return Ok((basename(&path, extension)?, path));
543 }
544
545 // Maximum number of iterations
546 const MAX: usize = 999;
547
548 let candidate = (1..=MAX)
549 .map(|n| format!("{name} {n}"))
550 .try_fold((), |_, name| {
551 let path = name_to_path(&name);
552 match fs::exists(&path).map_err(Error::from) {
553 Ok(false) => {
554 ControlFlow::Break(basename(&path, extension).map(|name| (name, path)))
555 }
556 Err(e) => ControlFlow::Break(Err(e)),
557 _ => ControlFlow::Continue(()),
558 }
559 });
560
561 match candidate {
562 ControlFlow::Break(r) => r,
563 ControlFlow::Continue(..) => Err(Error::MaxAttemptsExceeded {
564 name: name.to_string(),
565 max_attempts: MAX,
566 }),
567 }
568}
569
570impl Vault {
571 /// Returns a [`Vec`] of entries as [`VaultEntry`]s. Entries can be either directories or
572 /// files. If the directory is marked hidden with a dot (`.`) prefix it will be filtered out
573 /// from the resulting [`Vec`].
574 ///
575 /// The returned entries are not sorted.
576 ///
577 /// # Examples
578 ///
579 /// ```
580 /// use std::result;
581 /// use tempfile::tempdir;
582 /// use basalt_core::obsidian::{self, Vault, Note, Error};
583 ///
584 /// let tmp_dir = tempdir()?;
585 ///
586 /// let vault = Vault {
587 /// path: tmp_dir.path().to_path_buf(),
588 /// ..Default::default()
589 /// };
590 ///
591 /// (1..=5).try_for_each(|n| -> result::Result<(), Error> {
592 /// _ = obsidian::vault::create_untitled_note(&vault.path)?;
593 /// Ok(())
594 /// })?;
595 ///
596 /// assert_eq!(vault.entries().len(), 5);
597 ///
598 /// # Ok::<(), Error>(())
599 /// ```
600 /// TODO: Add Options struct to configure e.g. filters. Currently all hidden folders are filtered.
601 pub fn entries(&self) -> Vec<VaultEntry> {
602 match self.path.as_path().try_into() {
603 Ok(VaultEntry::Directory { entries, .. }) => entries
604 .into_iter()
605 .filter(|entry| !entry.name().starts_with('.'))
606 .collect(),
607 _ => vec![],
608 }
609 }
610}
611
612impl<'de> Deserialize<'de> for Vault {
613 fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
614 where
615 D: Deserializer<'de>,
616 {
617 #[derive(Deserialize)]
618 struct Json {
619 path: PathBuf,
620 open: Option<bool>,
621 ts: Option<u64>,
622 }
623
624 impl TryFrom<Json> for Vault {
625 type Error = String;
626 fn try_from(Json { path, open, ts }: Json) -> result::Result<Self, Self::Error> {
627 let name = basename(&path, None).map_err(|e| e.to_string())?;
628
629 Ok(Vault {
630 name,
631 path,
632 open: open.unwrap_or(false),
633 ts: ts.unwrap_or(0),
634 })
635 }
636 }
637
638 let deserialized: Json = Deserialize::deserialize(deserializer)?;
639 deserialized.try_into().map_err(serde::de::Error::custom)
640 }
641}