at main 258 lines 9.5 kB view raw
1use markdown_weaver::CowStr; 2use miette::IntoDiagnostic; 3use n0_future::TryFutureExt; 4use std::{path::Path, sync::OnceLock}; 5 6#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 7use regex::Regex; 8#[cfg(all(target_family = "wasm", target_os = "unknown"))] 9use regex_lite::Regex; 10 11use markdown_weaver::BrokenLink; 12use std::path::PathBuf; 13use std::sync::Arc; 14use unicode_bidi::{get_base_direction, Direction}; 15use unicode_normalization::UnicodeNormalization; 16 17#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 18pub async fn inline_file(path: impl AsRef<Path>) -> Option<String> { 19 tokio::fs::read_to_string(path).await.ok() 20} 21#[cfg(all(target_family = "wasm", target_os = "unknown"))] 22pub async fn inline_file(path: impl AsRef<Path>) -> Option<String> { 23 todo!() 24} 25 26pub const AVOID_URL_CHARS: &[char] = &[ 27 '!', '#', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '@', '%', '[', ']', '?', '/', 28 '~', '|', '{', '}', '^', '`', 29]; 30 31pub fn resolve_at_ident_or_uri<'s>( 32 link: &markdown_weaver::Tag<'s>, 33 appview: &str, 34) -> markdown_weaver::Tag<'s> { 35 use markdown_weaver::Tag; 36 match link { 37 Tag::Link { 38 link_type, 39 dest_url, 40 title, 41 id, 42 } => { 43 if dest_url.starts_with("at://") { 44 // Make the appview string swappable 45 let at_uri = weaver_common::aturi_to_http(dest_url.as_ref(), appview); 46 if let Some(at_uri) = at_uri { 47 Tag::Link { 48 link_type: *link_type, 49 dest_url: at_uri.into_static(), 50 title: title.clone(), 51 id: id.clone(), 52 } 53 } else { 54 link.clone() 55 } 56 } else if dest_url.starts_with("@") { 57 let maybe_identifier = dest_url.strip_prefix("@").unwrap(); 58 if let Some(identifier) = weaver_common::match_identifier(maybe_identifier) { 59 let link = CowStr::Boxed( 60 format!("https://{}/profile/{}", appview, identifier).into_boxed_str(), 61 ); 62 Tag::Link { 63 link_type: *link_type, 64 dest_url: link, 65 title: title.clone(), 66 id: id.clone(), 67 } 68 } else { 69 link.clone() 70 } 71 } else { 72 link.clone() 73 } 74 } 75 _ => link.clone(), 76 } 77} 78 79/// Rough and ready check if a path is a local path. 80/// Basically checks if the path is absolute and if so, is it accessible. 81/// Relative paths are assumed to be local, but URL schemes are not 82pub fn is_local_path(path: &str) -> bool { 83 // Check for URL schemes (http, https, at, etc.) 84 if path.contains("://") { 85 return false; 86 } 87 let path = Path::new(path); 88 path.is_relative() || path.try_exists().unwrap_or(false) 89} 90 91/// Is this link relative to somewhere? 92/// Rust has built-in checks for file paths, so this just wraps that. 93pub fn is_relative_link(link: &str) -> bool { 94 let path = Path::new(link); 95 path.is_relative() 96} 97 98/// Flatten a directory path to just the parent and filename, if present. 99/// Maybe worth to swap to using the Path tools, but this works. 100pub fn flatten_dir_to_just_one_parent(path: &str) -> (&str, &str) { 101 static RE_PARENT_DIR: OnceLock<Regex> = OnceLock::new(); 102 let caps = RE_PARENT_DIR 103 .get_or_init(|| { 104 Regex::new(r".*[/\\](?P<parent>[^/\\]+)[/\\](?P<filename>[^/\\]+)$").unwrap() 105 }) 106 .captures(path); 107 if let Some(caps) = caps { 108 if let Some(parent) = caps.name("parent") { 109 if let Some(filename) = caps.name("filename") { 110 return (parent.as_str(), filename.as_str()); 111 } 112 return (parent.as_str(), ""); 113 } 114 if let Some(filename) = caps.name("filename") { 115 return ("", filename.as_str()); 116 } 117 } 118 ("", path) 119} 120 121#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 122use tokio::fs::{self, File}; 123 124#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 125pub async fn create_file(dest: &Path) -> miette::Result<File> { 126 let file = File::create(dest) 127 .or_else(async |err| { 128 { 129 if err.kind() == std::io::ErrorKind::NotFound { 130 let parent = dest.parent().expect("file should have a parent directory"); 131 fs::create_dir_all(parent).await? 132 } 133 File::create(dest) 134 } 135 .await 136 }) 137 .await 138 .into_diagnostic()?; 139 Ok(file) 140} 141 142/// Path lookup in an Obsidian vault 143/// 144/// Credit to https://github.com/zoni 145/// 146/// Taken from https://github.com/zoni/obsidian-export/blob/main/src/lib.rs.rs on 2025-05-21 147/// 148pub fn lookup_filename_in_vault<'a>( 149 filename: &str, 150 vault_contents: &'a [PathBuf], 151) -> Option<&'a PathBuf> { 152 let filename = PathBuf::from(filename); 153 let filename_normalized: String = filename.to_string_lossy().nfc().collect(); 154 155 vault_contents.iter().find(|path| { 156 let path_normalized_str: String = path.to_string_lossy().nfc().collect(); 157 let path_normalized = PathBuf::from(&path_normalized_str); 158 let path_normalized_lowered = PathBuf::from(&path_normalized_str.to_lowercase()); 159 160 // It would be convenient if we could just do `filename.set_extension("md")` at the start 161 // of this funtion so we don't need multiple separate + ".md" match cases here, however 162 // that would break with a reference of `[[Note.1]]` linking to `[[Note.1.md]]`. 163 164 path_normalized.ends_with(&filename_normalized) 165 || path_normalized.ends_with(filename_normalized.clone() + ".md") 166 || path_normalized_lowered.ends_with(filename_normalized.to_lowercase()) 167 || path_normalized_lowered.ends_with(filename_normalized.to_lowercase() + ".md") 168 }) 169} 170 171pub struct VaultBrokenLinkCallback { 172 pub vault_contents: Arc<[PathBuf]>, 173} 174 175impl<'input> markdown_weaver::BrokenLinkCallback<'input> for VaultBrokenLinkCallback { 176 fn handle_broken_link( 177 &mut self, 178 link: BrokenLink<'input>, 179 ) -> Option<(CowStr<'input>, CowStr<'input>)> { 180 let text = link.reference; 181 let captures = crate::OBSIDIAN_NOTE_LINK_RE 182 .captures(&text) 183 .expect("note link regex didn't match - bad input?"); 184 let file = captures.name("file").map(|v| v.as_str().trim()); 185 let label = captures.name("label").map(|v| v.as_str()); 186 let section = captures.name("section").map(|v| v.as_str().trim()); 187 188 if let Some(file) = file { 189 if let Some(path) = lookup_filename_in_vault(file, self.vault_contents.as_ref()) { 190 let mut link_text = String::from(path.to_string_lossy()); 191 if let Some(section) = section { 192 link_text.push('#'); 193 link_text.push_str(section); 194 if let Some(label) = label { 195 let label = label.to_string(); 196 Some((CowStr::from(link_text), CowStr::from(label))) 197 } else { 198 Some((link_text.into(), format!("{} > {}", file, section).into())) 199 } 200 } else { 201 Some((link_text.into(), format!("{}", file).into())) 202 } 203 } else { 204 None 205 } 206 } else { 207 None 208 } 209 } 210} 211 212/// Detect text direction from first strong directional character. 213/// Returns Some("rtl") for Hebrew/Arabic/etc, Some("ltr") for Latin, None if no strong char found. 214pub fn detect_text_direction(text: &str) -> Option<&'static str> { 215 match get_base_direction(text) { 216 Direction::Ltr => Some("ltr"), 217 Direction::Rtl => Some("rtl"), 218 Direction::Mixed => None, // neutral/unknown - let browser decide 219 } 220} 221 222#[cfg(test)] 223mod tests { 224 use super::*; 225 226 #[test] 227 fn test_detect_text_direction_ltr() { 228 assert_eq!(detect_text_direction("Hello World"), Some("ltr")); 229 assert_eq!(detect_text_direction("Привет мир"), Some("ltr")); 230 assert_eq!(detect_text_direction("你好世界"), Some("ltr")); 231 assert_eq!(detect_text_direction("Γειά σου κόσμε"), Some("ltr")); 232 } 233 234 #[test] 235 fn test_detect_text_direction_rtl() { 236 // Hebrew 237 assert_eq!(detect_text_direction("שלום עולם"), Some("rtl")); 238 // Arabic 239 assert_eq!(detect_text_direction("مرحبا بالعالم"), Some("rtl")); 240 // Mixed with leading whitespace and punctuation 241 assert_eq!(detect_text_direction(" 123... שלום"), Some("rtl")); 242 assert_eq!(detect_text_direction(" 456!!! مرحبا"), Some("rtl")); 243 } 244 245 #[test] 246 fn test_detect_text_direction_neutral_only() { 247 assert_eq!(detect_text_direction(" "), None); 248 assert_eq!(detect_text_direction("123456"), None); 249 assert_eq!(detect_text_direction("!!!..."), None); 250 assert_eq!(detect_text_direction(""), None); 251 } 252 253 #[test] 254 fn test_detect_text_direction_leading_neutrals() { 255 assert_eq!(detect_text_direction(" 123... Hello"), Some("ltr")); 256 assert_eq!(detect_text_direction("!!!456 שלום"), Some("rtl")); 257 } 258}