Skip to main content

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}