use std::fs;
use std::path::{Path, PathBuf};
pub const MAX_VAULTS: usize = 10;
fn bookmarks_path() -> Option<PathBuf> {
crate::config::config_dir()
.ok()
.map(|d| d.join("bookmarks"))
}
fn vaults_path() -> Option<PathBuf> {
crate::config::config_dir().ok().map(|d| d.join("vaults"))
}
pub fn load(root: &Path) -> Vec<String> {
let Some(file) = bookmarks_path() else {
return Vec::new();
};
match fs::read_to_string(&file) {
Ok(text) => lines_of(&text),
Err(_) => {
let seeded = fs::read_to_string(root.join(".obsidian/bookmarks.json"))
.map(|json| from_obsidian(&json))
.unwrap_or_default();
if !seeded.is_empty() {
store(&file, &seeded);
}
seeded
}
}
}
pub fn toggle(root: &Path, path: &Path) -> bool {
let rel = relative(root, path);
let mut list = load(root);
let added = if let Some(i) = list.iter().position(|p| *p == rel) {
list.remove(i);
false
} else {
list.push(rel);
true
};
if let Some(file) = bookmarks_path() {
store(&file, &list);
}
added
}
fn relative(root: &Path, path: &Path) -> String {
let canon = |p: &Path| fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let (root, path) = (canon(root), canon(path));
path.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned()
}
fn lines_of(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
fn store(file: &Path, list: &[String]) {
if let Some(dir) = file.parent() {
let _ = fs::create_dir_all(dir);
}
let body: String = list.iter().map(|p| format!("{p}\n")).collect();
let _ = fs::write(file, body);
}
pub fn from_obsidian(json: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = json;
while let Some(i) = rest.find("\"type\"") {
let after = &rest[i + "\"type\"".len()..];
let Some(kind) = string_after_colon(after) else {
break;
};
let end = after.find("\"type\"").unwrap_or(after.len());
let item = &after[..end];
if kind == "file" {
if let Some(path) = item
.find("\"path\"")
.and_then(|p| string_after_colon(&item[p + "\"path\"".len()..]))
{
if !path.is_empty() && !out.contains(&path) {
out.push(path);
}
}
}
rest = after;
}
out
}
fn string_after_colon(s: &str) -> Option<String> {
let s = s.trim_start().strip_prefix(':')?.trim_start();
let mut chars = s.strip_prefix('"')?.chars();
let mut out = String::new();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
'u' => {
let hex: String = chars.by_ref().take(4).collect();
if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
out.push(ch);
}
}
other => out.push(other),
},
_ => out.push(c),
}
}
None
}
pub fn vaults() -> Vec<PathBuf> {
let Some(file) = vaults_path() else {
return Vec::new();
};
let Ok(text) = fs::read_to_string(file) else {
return Vec::new();
};
lines_of(&text)
.into_iter()
.map(PathBuf::from)
.filter(|p| p.is_dir())
.take(MAX_VAULTS)
.collect()
}
pub fn push_vault(root: &Path) {
let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut list = vaults();
list.retain(|p| *p != root);
list.insert(0, root);
list.truncate(MAX_VAULTS);
if let Some(file) = vaults_path() {
let body: Vec<String> = list.iter().map(|p| p.display().to_string()).collect();
store(&file, &body);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn obsidian_bookmarks_yield_their_file_paths_in_order() {
let json = r#"{
"items": [
{ "type": "file", "ctime": 1, "path": "notes/spec.md", "title": "Spec" },
{ "type": "group", "title": "Work", "items": [
{ "type": "file", "path": "work/plan.md" },
{ "type": "folder", "path": "work" },
{ "type": "search", "query": "tag:#x" }
] },
{ "type": "file", "path": "with \"quote\".md" },
{ "type": "file", "path": "notes/spec.md" }
]
}"#;
assert_eq!(
from_obsidian(json),
vec!["notes/spec.md", "work/plan.md", "with \"quote\".md"]
);
assert!(from_obsidian("{}").is_empty());
assert!(from_obsidian("not json").is_empty());
}
#[test]
fn a_path_under_the_root_is_kept_relative() {
let dir = crate::testutil::tmpdir("bookmarks", "relative");
let note = crate::testutil::write(&dir, "a/b.md", "# B\n");
assert_eq!(relative(&dir, ¬e), "a/b.md");
let _ = fs::remove_dir_all(&dir);
}
}