basalt_core/obsidian/config.rs
1//! This module provides functionality operating with Obsidian config.
2use dirs::{config_dir, home_dir};
3
4use serde::{Deserialize, Deserializer};
5use std::path::Path;
6use std::{collections::BTreeMap, fs, path::PathBuf};
7use std::{env, result};
8
9use crate::obsidian::{Error, Result, Vault};
10
11/// Represents the Obsidian configuration, typically loaded from an `obsidian.json` file.
12#[derive(Debug, Clone, Default, PartialEq)]
13pub struct ObsidianConfig {
14 /// A mapping of vault (folder) names to [`Vault`] definitions.
15 vaults: BTreeMap<String, Vault>,
16}
17
18/// Attempts to locate and load the system's `obsidian.json` file as an [`ObsidianConfig`].
19///
20/// Returns an [`Error`] if the file path doesn't exist or JSON parsing failed.
21pub fn load() -> Result<ObsidianConfig> {
22 let config_locations = obsidian_global_config_locations();
23 let existing_config_locations = config_locations
24 .iter()
25 .filter(|path| path.is_dir())
26 .collect::<Vec<_>>();
27
28 if let Some(config_dir) = existing_config_locations.first() {
29 load_from(config_dir)
30 } else {
31 Err(Error::PathNotFound(format!(
32 "Obsidian config directory was not found from these locations: {}",
33 config_locations
34 .iter()
35 .map(|path| path.to_string_lossy())
36 .collect::<Vec<_>>()
37 .join(", ")
38 )))
39 }
40}
41
42/// Attempts to load `obsidian.json` file as an [`ObsidianConfig`] from the given directory
43/// [`Path`].
44///
45/// Returns an [`Error`] if the file path doesn't exist or JSON parsing failed.
46///
47/// # Examples
48///
49/// ```
50/// use std::path::Path;
51/// use basalt_core::obsidian;
52///
53/// _ = obsidian::config::load_from(Path::new("./dir-with-config-file"));
54/// ```
55pub fn load_from(config_path: &Path) -> Result<ObsidianConfig> {
56 let obsidian_json_path = config_path.join("obsidian.json");
57
58 if obsidian_json_path.try_exists()? {
59 let contents = fs::read_to_string(obsidian_json_path)?;
60 serde_json::from_str(&contents).map_err(Error::Json)
61 } else {
62 // TODO: Maybe a different error should be propagated in this case. E.g. 'unreadable'
63 // file.
64 Err(Error::PathNotFound(
65 obsidian_json_path.to_string_lossy().to_string(),
66 ))
67 }
68}
69
70impl ObsidianConfig {
71 /// Returns a vec of vaults in the configuration.
72 ///
73 /// # Examples
74 ///
75 /// ```
76 /// use basalt_core::obsidian::{ObsidianConfig, Vault};
77 ///
78 /// let config = ObsidianConfig::from([
79 /// ("Obsidian", Vault::default()),
80 /// ("Work", Vault::default()),
81 /// ]);
82 ///
83 /// let vaults = config.vaults();
84 ///
85 /// assert_eq!(vaults.len(), 2);
86 /// assert_eq!(vaults.get(0), Some(&Vault::default()).as_ref());
87 /// ```
88 pub fn vaults(&self) -> Vec<&Vault> {
89 self.vaults.values().collect()
90 }
91}
92
93impl<const N: usize> From<[(&str, Vault); N]> for ObsidianConfig {
94 fn from(arr: [(&str, Vault); N]) -> Self {
95 Self {
96 vaults: BTreeMap::from(arr.map(|(name, vault)| (name.to_owned(), vault))),
97 }
98 }
99}
100
101impl<const N: usize> From<[(String, Vault); N]> for ObsidianConfig {
102 fn from(arr: [(String, Vault); N]) -> Self {
103 Self {
104 vaults: BTreeMap::from(arr),
105 }
106 }
107}
108
109impl<'de> Deserialize<'de> for ObsidianConfig {
110 fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
111 where
112 D: Deserializer<'de>,
113 {
114 #[derive(Deserialize)]
115 struct Json {
116 vaults: BTreeMap<String, Vault>,
117 }
118
119 impl From<Json> for ObsidianConfig {
120 fn from(value: Json) -> Self {
121 ObsidianConfig {
122 vaults: value
123 .vaults
124 .into_values()
125 .map(|vault| (vault.name.clone(), vault))
126 .collect(),
127 }
128 }
129 }
130
131 let deserialized: Json = Deserialize::deserialize(deserializer)?;
132 Ok(deserialized.into())
133 }
134}
135
136/// Returns all existing configuration directory paths where Obsidian might store its global
137/// settings.
138///
139/// This function determines possible configuration locations by platform-specific conventions and
140/// installation methods. On all platforms, it first checks if the user has defined the
141/// `OBSIDIAN_CONFIG_DIR` environment variable. If so, that path is used, and any leading tilde (~)
142/// is expanded to the current user's home directory.
143///
144/// On Windows, it then resolves to the default Obsidian directory located under the system's
145/// application data folder, typically `%APPDATA%\Obsidian`. On macOS, the function expects to find
146/// the configuration under `~/Library/Application Support/obsidian`. On Linux, the standard config
147/// directory is assumed to be `$XDG_CONFIG_HOME/obsidian`, or `~/.config/obsidian`.
148///
149/// For Linux users, the function also accounts for sandboxed installations. If Obsidian is
150/// installed via Flatpak, the configuration is likely found in
151/// `~/.var/app/md.obsidian.Obsidian/config/obsidian`. For Snap installations, the relevant path is
152/// typically `~/snap/obsidian/common/.config/obsidian`.
153///
154/// For reference:
155/// - macOS: `/Users/username/Library/Application Support/obsidian`
156/// - Windows: `%APPDATA%\Obsidian\`
157/// - Linux: `$XDG_CONFIG_HOME/obsidian` or `~/.config/obsidian`
158/// - flatpak: `$HOME/.var/app/md.obsidian.Obsidian/config/obsidian`
159/// - snap: `$HOME/snap/obsidian/current/.config/obsidian`
160///
161/// More info: [https://help.obsidian.md/Files+and+folders/How+Obsidian+stores+data]
162pub fn obsidian_global_config_locations() -> Vec<PathBuf> {
163 #[cfg(any(target_os = "macos", target_os = "linux"))]
164 const OBSIDIAN_CONFIG_DIR_NAME: &str = "obsidian";
165
166 #[cfg(target_os = "windows")]
167 const OBSIDIAN_CONFIG_DIR_NAME: &str = "Obsidian";
168
169 let override_path =
170 env::var("OBSIDIAN_CONFIG_DIR")
171 .ok()
172 .zip(home_dir())
173 .map(|(path, home_dir)| {
174 PathBuf::from(path.replace("~", home_dir.to_string_lossy().as_ref()))
175 });
176
177 let default_config_path =
178 config_dir().map(|config_path| config_path.join(OBSIDIAN_CONFIG_DIR_NAME));
179
180 #[cfg(any(target_os = "macos", target_os = "windows"))]
181 let sandboxed_paths: [Option<PathBuf>; 0] = [];
182
183 // In cases where user has a sandboxes instance of Obsidian installed under either flatpak or
184 // snap, we must check if the configuration exists under these locations.
185 #[cfg(target_os = "linux")]
186 let sandboxed_paths = {
187 let flatpak_path = home_dir().map(|home_dir| {
188 home_dir
189 .join(".var/app/md.obsidian.Obsidian/config")
190 .join(OBSIDIAN_CONFIG_DIR_NAME)
191 });
192
193 let snap_path = home_dir().map(|home_dir| {
194 home_dir
195 .join("snap/obsidian/current/.config")
196 .join(OBSIDIAN_CONFIG_DIR_NAME)
197 });
198
199 [flatpak_path, snap_path]
200 };
201
202 let base_paths = [override_path, default_config_path];
203
204 base_paths
205 .into_iter()
206 .chain(sandboxed_paths)
207 .flatten()
208 .collect()
209}