1use serde::Serialize;
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24const SKIPPED: &[&str] = &["node_modules", "target", ".git", ".obsidian", ".trash"];
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct NoteLink {
30 pub target: String,
32 pub anchor: String,
34 pub text: String,
36 pub line: usize,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct Checkbox {
43 pub line: usize,
44 pub done: bool,
45 pub text: String,
46}
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum NoteKind {
53 #[default]
54 Text,
55 Image,
56 Pdf,
57 Audio,
58}
59
60pub fn kind_of_file(name: &str) -> Option<NoteKind> {
64 let extension = name.rsplit_once('.')?.1.to_lowercase();
65 Some(match extension.as_str() {
66 "md" | "txt" => NoteKind::Text,
67 "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" => NoteKind::Image,
68 "pdf" => NoteKind::Pdf,
69 "mp3" | "ogg" | "oga" | "opus" | "m4a" | "aac" | "wav" | "flac" | "webm" | "3gp" | "amr" => NoteKind::Audio,
70 _ => return None,
71 })
72}
73
74pub fn text_stem(name: &str) -> &str {
77 for extension in [".md", ".txt"] {
78 let cut = name.len().saturating_sub(extension.len());
79 if name.len() > extension.len() && name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(extension) {
80 return &name[..cut];
81 }
82 }
83 name
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
88pub struct Note {
89 pub path: String,
91 pub kind: NoteKind,
93 pub title: String,
95 pub tags: Vec<String>,
96 pub aliases: Vec<String>,
97 pub links: Vec<NoteLink>,
98 pub checkboxes: Vec<Checkbox>,
99 pub front: BTreeMap<String, Vec<String>>,
101 pub modified: i64,
103}
104
105impl Note {
106 pub fn uri(&self) -> String {
108 uri_of(&self.path)
109 }
110
111 pub fn stem(&self) -> &str {
113 let name = self.path.rsplit('/').next().unwrap_or(&self.path);
114 text_stem(name)
115 }
116
117 pub fn folder(&self) -> &str {
119 self.path.rsplit_once('/').map_or("", |(folder, _)| folder)
120 }
121}
122
123pub fn trash(root: &Path, path: &str) -> Result<String, String> {
126 let path = normalize(path).ok_or_else(|| format!("{path}: outside the notes"))?;
127 let mut target = format!(".trash/{path}");
128 let mut n = 2;
129 while root.join(&target).exists() {
130 let (stem, extension) = path.rsplit_once('.').unwrap_or((&path, ""));
131 target = if extension.is_empty() { format!(".trash/{stem} {n}") } else { format!(".trash/{stem} {n}.{extension}") };
132 n += 1;
133 }
134 let to = root.join(&target);
135 if let Some(parent) = to.parent() {
136 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
137 }
138 std::fs::rename(root.join(&path), &to).map_err(|e| format!("{path}: {e}"))?;
139 Ok(target)
140}
141
142pub fn untrash(root: &Path, trashed: &str, path: &str) -> Result<(), String> {
144 let to = root.join(normalize(path).ok_or_else(|| format!("{path}: outside the notes"))?);
145 let from = normalize(trashed).filter(|t| t.starts_with(".trash/")).ok_or_else(|| format!("{trashed}: outside the notes"))?;
147 if to.exists() {
148 return Err(format!("{path}: taken again"));
149 }
150 std::fs::rename(root.join(from), &to).map_err(|e| format!("{path}: {e}"))
151}
152
153pub fn rename(vault: &Vault, path: &str, name: &str) -> Result<(String, Vec<String>), String> {
158 let note = vault.note(path).ok_or_else(|| format!("{path}: no such note"))?;
159 let clean: String = name.trim().chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '#' | '^' | '[' | ']') && !c.is_control()).collect();
160 let clean = text_stem(clean.trim().trim_start_matches('.')).trim().to_string();
162 if clean.is_empty() {
163 return Err(format!("{path}: a note needs a name"));
164 }
165 let clean = not_a_device(clean);
166 let extension = path.rsplit_once('.').map_or("md", |(_, e)| e);
167 let folder = note.folder();
168 let file = format!("{clean}.{extension}");
169 let new_path = if folder.is_empty() { file.clone() } else { format!("{folder}/{file}") };
170 if new_path == path {
171 return Ok((new_path, Vec::new()));
172 }
173 if vault.root.join(&new_path).exists() {
174 return Err(format!("{new_path}: taken"));
175 }
176 std::fs::rename(vault.root.join(path), vault.root.join(&new_path)).map_err(|e| format!("{path}: {e}"))?;
177 let old_name = path.rsplit('/').next().unwrap_or(path);
179 let old_stem = if note.kind == NoteKind::Text { text_stem(old_name) } else { old_name };
180 let new_stem = if note.kind == NoteKind::Text { clean.as_str() } else { file.as_str() };
181 let mut changed = Vec::new();
182 for other in vault.notes.iter().filter(|n| n.kind == NoteKind::Text && n.path != path) {
183 let Ok(text) = std::fs::read_to_string(vault.root.join(&other.path)) else { continue };
184 let mut next = text.clone();
185 for (old, new) in [
186 (format!("[[{old_stem}]]"), format!("[[{new_stem}]]")),
187 (format!("[[{old_stem}|"), format!("[[{new_stem}|")),
188 (format!("[[{old_stem}#"), format!("[[{new_stem}#")),
189 (format!("[[{}]]", text_stem(path)), format!("[[{}]]", text_stem(&new_path))),
190 (uri_of(path), uri_of(&new_path)),
191 (format!("]({})", encode_path(&relative(other.folder(), path))), format!("]({})", encode_path(&relative(other.folder(), &new_path)))),
192 (format!("](<{}>)", relative(other.folder(), path)), format!("](<{}>)", relative(other.folder(), &new_path))),
193 ] {
194 next = next.replace(&old, &new);
195 }
196 if next != text {
197 write(&vault.root, &other.path, &next).map_err(|e| format!("{}: {e}", other.path))?;
198 changed.push(other.path.clone());
199 }
200 }
201 Ok((new_path, changed))
202}
203
204fn folder_name(name: &str) -> Result<String, String> {
206 let clean: String = name.trim().chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '#' | '^' | '[' | ']')).collect();
207 let clean = clean.trim().trim_start_matches('.').trim().to_string();
208 if clean.is_empty() { Err(format!("{name}: a folder needs a name")) } else { Ok(not_a_device(clean)) }
209}
210
211fn not_a_device(name: String) -> String {
216 let at = name.find('.').unwrap_or(name.len());
217 let base = name[..at].trim().to_ascii_lowercase();
218 let numbered = base.len() == 4 && (base.starts_with("com") || base.starts_with("lpt")) && base.as_bytes()[3].is_ascii_digit();
219 if numbered || matches!(base.as_str(), "con" | "prn" | "aux" | "nul") { format!("{}_{}", &name[..at], &name[at..]) } else { name }
220}
221
222pub fn make_folder(root: &Path, parent: &str, name: &str) -> Result<String, String> {
224 let name = folder_name(name)?;
225 let parent = normalize(parent).unwrap_or_default();
226 let path = if parent.is_empty() { name } else { format!("{parent}/{name}") };
227 if root.join(&path).exists() {
228 return Err(format!("{path}: taken"));
229 }
230 std::fs::create_dir_all(root.join(&path)).map_err(|e| format!("{path}: {e}"))?;
231 Ok(path)
232}
233
234pub fn rename_folder(vault: &Vault, path: &str, name: &str) -> Result<(String, Vec<(String, String)>), String> {
238 let path = normalize(path).ok_or_else(|| format!("{path}: outside the notes"))?;
239 let name = folder_name(name)?;
240 let new_path = match path.rsplit_once('/') {
241 Some((parent, _)) => format!("{parent}/{name}"),
242 None => name,
243 };
244 if new_path == path {
245 return Ok((new_path, Vec::new()));
246 }
247 if vault.root.join(&new_path).exists() {
248 return Err(format!("{new_path}: taken"));
249 }
250 std::fs::rename(vault.root.join(&path), vault.root.join(&new_path)).map_err(|e| format!("{path}: {e}"))?;
251 let prefix = format!("{path}/");
252 let moved: Vec<(String, String)> = vault.notes.iter().filter_map(|n| n.path.strip_prefix(&prefix).map(|rest| (n.path.clone(), format!("{new_path}/{rest}")))).collect();
253 for other in vault.notes.iter().filter(|n| n.kind == NoteKind::Text) {
254 let here = moved.iter().find(|(from, _)| *from == other.path).map_or(other.path.clone(), |(_, to)| to.clone());
255 let Ok(text) = std::fs::read_to_string(vault.root.join(&here)) else { continue };
256 let mut next = text.clone();
257 for (from, to) in &moved {
258 for (old, new) in [
259 (format!("[[{}]]", text_stem(from)), format!("[[{}]]", text_stem(to))),
260 (format!("[[{}|", text_stem(from)), format!("[[{}|", text_stem(to))),
261 (uri_of(from), uri_of(to)),
262 (format!("]({})", encode_path(&relative(other.folder(), from))), format!("]({})", encode_path(&relative(other.folder(), to)))),
263 ] {
264 next = next.replace(&old, &new);
265 }
266 }
267 if next != text {
268 write(&vault.root, &here, &next)?;
269 }
270 }
271 Ok((new_path, moved))
272}
273
274pub fn remove_folder(root: &Path, path: &str) -> Result<(), String> {
276 let path = normalize(path).filter(|p| !p.is_empty()).ok_or_else(|| format!("{path}: outside the notes"))?;
277 let full = root.join(&path);
278 let empty = std::fs::read_dir(&full).map_err(|e| format!("{path}: {e}"))?.next().is_none();
279 if !empty {
280 return Err(format!("{path}: not empty"));
281 }
282 std::fs::remove_dir(&full).map_err(|e| format!("{path}: {e}"))
283}
284
285fn relative(from_folder: &str, to: &str) -> String {
287 let from: Vec<&str> = from_folder.split('/').filter(|p| !p.is_empty()).collect();
288 let to_parts: Vec<&str> = to.split('/').collect();
289 let common = from.iter().zip(&to_parts).take_while(|(a, b)| a == b).count();
290 let mut parts: Vec<&str> = std::iter::repeat_n("..", from.len() - common).collect();
291 parts.extend(&to_parts[common..]);
292 parts.join("/")
293}
294
295pub fn image_size(path: &Path) -> Option<(u32, u32)> {
297 use std::io::Read;
298 let mut head = Vec::with_capacity(64 * 1024);
299 std::fs::File::open(path).ok()?.take(256 * 1024).read_to_end(&mut head).ok()?;
300 let be16 = |b: &[u8], at: usize| b.get(at..at + 2).map(|x| u32::from(u16::from_be_bytes([x[0], x[1]])));
301 let le16 = |b: &[u8], at: usize| b.get(at..at + 2).map(|x| u32::from(u16::from_le_bytes([x[0], x[1]])));
302 let be32 = |b: &[u8], at: usize| b.get(at..at + 4).map(|x| u32::from_be_bytes([x[0], x[1], x[2], x[3]]));
303 if head.starts_with(b"\x89PNG\r\n\x1a\n") {
304 return Some((be32(&head, 16)?, be32(&head, 20)?));
305 }
306 if head.starts_with(b"GIF8") {
307 return Some((le16(&head, 6)?, le16(&head, 8)?));
308 }
309 if head.starts_with(b"RIFF") && head.get(8..12) == Some(b"WEBP") {
310 return match head.get(12..16)? {
311 b"VP8X" => {
312 let three = |at: usize| head.get(at..at + 3).map(|x| u32::from(x[0]) | u32::from(x[1]) << 8 | u32::from(x[2]) << 16);
313 Some((three(24)? + 1, three(27)? + 1))
314 }
315 b"VP8 " => Some((le16(&head, 26)? & 0x3fff, le16(&head, 28)? & 0x3fff)),
316 b"VP8L" => {
317 let bits = head.get(21..25).map(|x| u32::from_le_bytes([x[0], x[1], x[2], x[3]]))?;
318 Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1))
319 }
320 _ => None,
321 };
322 }
323 if head.starts_with(&[0xff, 0xd8]) {
324 let mut at = 2;
326 while at + 9 < head.len() {
327 if head[at] != 0xff {
328 at += 1;
329 continue;
330 }
331 let marker = head[at + 1];
332 if (0xc0..=0xcf).contains(&marker) && ![0xc4, 0xc8, 0xcc].contains(&marker) {
333 return Some((be16(&head, at + 7)?, be16(&head, at + 5)?));
334 }
335 at += 2 + usize::try_from(be16(&head, at + 2)?).ok()?;
336 }
337 }
338 None
339}
340
341pub fn uri_of(path: &str) -> String {
343 format!("sioul:note/{}", encode_path(path))
344}
345
346pub fn path_of(uri: &str) -> Option<String> {
348 let rest = uri.strip_prefix("sioul:note/")?;
349 let path = decode(rest.split('#').next().unwrap_or(rest));
350 let bytes: Option<Vec<u8>> = path.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect();
353 Some(bytes.filter(|b| b.iter().any(|&x| x >= 0x80)).and_then(|b| String::from_utf8(b).ok()).unwrap_or(path))
354}
355
356pub fn encode_path(path: &str) -> String {
360 let mut out = String::with_capacity(path.len());
361 for c in path.chars() {
362 if matches!(c, ' ' | '"' | '#' | '%' | '<' | '>' | '?' | '[' | ']' | '\\' | '^' | '`' | '{' | '|' | '}') || c.is_control() {
363 for byte in c.encode_utf8(&mut [0; 4]).bytes() {
364 out.push_str(&format!("%{byte:02X}"));
365 }
366 } else {
367 out.push(c);
368 }
369 }
370 out
371}
372
373pub fn file_url(path: &Path) -> String {
377 let text = path.to_string_lossy();
378 let text = if cfg!(windows) { text.replace('\\', "/").trim_start_matches("//?/").to_string() } else { text.to_string() };
380 match text {
381 share if share.starts_with("//") => format!("file:{}", encode_path(&share)),
383 rooted if rooted.starts_with('/') => format!("file://{}", encode_path(&rooted)),
384 drive => format!("file:///{}", encode_path(&drive)),
385 }
386}
387
388pub fn decode(text: &str) -> String {
390 let bytes = text.as_bytes();
391 let mut out = Vec::with_capacity(bytes.len());
392 let mut i = 0;
393 while i < bytes.len() {
394 if bytes[i] == b'%'
395 && let Some(byte) = text.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
396 {
397 out.push(byte);
398 i += 3;
399 continue;
400 }
401 out.push(bytes[i]);
402 i += 1;
403 }
404 String::from_utf8_lossy(&out).to_string()
405}
406
407pub fn front_matter(text: &str) -> (BTreeMap<String, Vec<String>>, usize) {
411 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
412 let all: Vec<&str> = text.lines().collect();
413 if all.first().map(|l| l.trim_end()) != Some("---") {
414 return (out, 0);
415 }
416 let Some(end) = all.iter().skip(1).position(|l| matches!(l.trim_end(), "---" | "...")) else { return (BTreeMap::new(), 0) };
417 let mut key: Option<String> = None;
418 for line in &all[1..=end] {
419 let trimmed = line.trim();
420 if trimmed.is_empty() || trimmed.starts_with('#') {
421 continue;
422 }
423 if let (Some(item), Some(k)) = (trimmed.strip_prefix("- "), key.as_ref()) {
424 out.entry(k.clone()).or_default().push(unquote(item));
425 continue;
426 }
427 let Some((k, value)) = trimmed.split_once(':') else { continue };
428 let k = k.trim().to_lowercase();
429 let value = value.trim();
430 key = Some(k.clone());
431 let entry = out.entry(k).or_default();
432 if let Some(list) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
433 entry.extend(list.split(',').map(unquote).filter(|v| !v.is_empty()));
434 } else if !value.is_empty() {
435 entry.push(unquote(value));
436 }
437 }
438 (out, end + 2)
439}
440
441fn unquote(value: &str) -> String {
442 let value = value.trim();
443 let quoted = value.len() >= 2 && ((value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\'')));
444 if quoted { value[1..value.len() - 1].to_string() } else { value.to_string() }
445}
446
447const FRONT_LINKS: &[&str] = &["task", "tasks", "event", "events", "mail", "mails", "contact", "contacts", "draft", "drafts", "links", "related", "case"];
451
452pub fn read(path: &str, text: &str) -> Note {
455 let (front, body_start) = front_matter(text);
456 let mut note = Note { path: path.to_string(), front: front.clone(), ..Note::default() };
457 note.tags.extend(front.get("tags").into_iter().flatten().chain(front.get("tag").into_iter().flatten()).map(|t| t.trim_start_matches('#').to_string()));
458 note.aliases.extend(front.get("aliases").into_iter().flatten().chain(front.get("alias").into_iter().flatten()).cloned());
459 for key in FRONT_LINKS {
460 for value in front.get(*key).into_iter().flatten() {
461 let target = if *key == "case" && !value.contains(':') { crate::links::project_uri(value) } else { value.clone() };
462 note.links.push(NoteLink { target, anchor: String::new(), text: key.to_string(), line: 1 });
463 }
464 }
465 let mut fenced = false;
466 for (n, line) in text.lines().enumerate().skip(body_start) {
467 let number = n + 1;
468 let trimmed = line.trim_start();
469 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
470 fenced = !fenced;
471 continue;
472 }
473 if fenced {
474 continue;
475 }
476 if note.title.is_empty()
477 && let Some(heading) = trimmed.strip_prefix("# ")
478 {
479 note.title = heading.trim().to_string();
480 }
481 if let Some(checkbox) = checkbox(trimmed, number) {
482 note.checkboxes.push(checkbox);
483 }
484 let plain = without_code(line);
485 links_in(&plain, number, &mut note.links);
486 for tag in tags_in(&plain) {
487 if !note.tags.contains(&tag) {
488 note.tags.push(tag);
489 }
490 }
491 }
492 if let Some(title) = front.get("title").and_then(|t| t.first()) {
493 note.title = title.clone();
494 }
495 if note.title.is_empty() {
496 note.title = note.stem().to_string();
497 }
498 note
499}
500
501fn checkbox(line: &str, number: usize) -> Option<Checkbox> {
503 let rest = line.strip_prefix(['-', '*', '+']).map(str::trim_start).or_else(|| {
504 let digits = line.chars().take_while(char::is_ascii_digit).count();
505 (digits > 0).then(|| line[digits..].strip_prefix(['.', ')'])).flatten().map(str::trim_start)
506 })?;
507 let (mark, text) = (rest.get(..3)?, rest.get(3..)?);
508 let done = match mark {
509 "[ ]" => false,
510 "[x]" | "[X]" => true,
511 _ => return None,
512 };
513 let text = text.trim();
514 (!text.is_empty()).then(|| Checkbox { line: number, done, text: text.to_string() })
515}
516
517fn without_code(line: &str) -> String {
519 let mut out = String::with_capacity(line.len());
520 let mut in_code = false;
521 for c in line.chars() {
522 if c == '`' {
523 in_code = !in_code;
524 out.push(' ');
525 } else {
526 out.push(if in_code { ' ' } else { c });
527 }
528 }
529 out
530}
531
532fn links_in(line: &str, number: usize, out: &mut Vec<NoteLink>) {
534 let mut rest = line;
536 while let Some(open) = rest.find("[[") {
537 let after = &rest[open + 2..];
538 let Some(close) = after.find("]]") else { break };
539 let inner = &after[..close];
540 let (target, text) = inner.split_once('|').map_or((inner, inner), |(t, x)| (t, x));
541 let (target, anchor) = target.split_once('#').unwrap_or((target, ""));
542 if !target.trim().is_empty() || !anchor.is_empty() {
543 out.push(NoteLink { target: format!("wiki:{}", target.trim()), anchor: anchor.trim().to_string(), text: text.trim().to_string(), line: number });
544 }
545 rest = &after[close + 2..];
546 }
547 let bytes = line.as_bytes();
549 let mut i = 0;
550 while let Some(found) = line[i..].find("](") {
551 let close_text = i + found;
552 let Some(open_text) = line[..close_text].rfind('[') else {
553 i = close_text + 2;
554 continue;
555 };
556 let start = close_text + 2;
557 let (target, end) = if bytes.get(start) == Some(&b'<') {
558 match line[start + 1..].find('>') {
559 Some(e) => (&line[start + 1..start + 1 + e], start + 1 + e + 1),
560 None => break,
561 }
562 } else {
563 let mut depth = 0;
564 let mut end = None;
565 for (k, c) in line[start..].char_indices() {
566 match c {
567 '(' => depth += 1,
568 ')' if depth == 0 => {
569 end = Some(start + k);
570 break;
571 }
572 ')' => depth -= 1,
573 _ => {}
574 }
575 }
576 let Some(end) = end else { break };
577 (line[start..end].split(" \"").next().unwrap_or(""), end)
578 };
579 let target = target.trim();
580 if !target.is_empty() {
581 let (target, anchor) = match target.split_once('#') {
582 Some((t, a)) if !t.contains(':') || t.starts_with("sioul:note/") => (t, a),
583 _ => (target, ""),
584 };
585 out.push(NoteLink { target: target.to_string(), anchor: decode(anchor), text: line[open_text + 1..close_text].trim().to_string(), line: number });
586 }
587 i = end.max(start);
588 }
589 for word in line.split_whitespace() {
591 let word = word.trim_matches(|c: char| matches!(c, '<' | '>' | '(' | ')' | ',' | ';' | '.'));
592 if (word.starts_with("mid:") || word.starts_with("sioul:")) && !out.iter().any(|l| l.line == number && l.target == word) {
593 out.push(NoteLink { target: word.to_string(), anchor: String::new(), text: String::new(), line: number });
594 }
595 }
596}
597
598fn tags_in(line: &str) -> Vec<String> {
600 let mut out = Vec::new();
601 let chars: Vec<char> = line.chars().collect();
602 for (i, &c) in chars.iter().enumerate() {
603 if c != '#' || (i > 0 && !chars[i - 1].is_whitespace()) {
604 continue;
605 }
606 let tag: String = chars[i + 1..].iter().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '/')).collect();
607 if !tag.is_empty() && !tag.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '/') && !out.contains(&tag) {
608 out.push(tag);
609 }
610 }
611 out
612}
613
614#[derive(Debug, Clone, Default)]
616pub struct Vault {
617 pub root: PathBuf,
618 pub notes: Vec<Note>,
619 pub folders: Vec<String>,
621}
622
623impl Vault {
624 pub fn open(root: &Path) -> Vault {
627 let mut notes = Vec::new();
628 let mut folders = Vec::new();
629 let mut stack = vec![root.to_path_buf()];
630 while let Some(dir) = stack.pop() {
631 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
632 for entry in entries.filter_map(Result::ok) {
633 let path = entry.path();
634 let name = entry.file_name().to_string_lossy().to_string();
635 if name.starts_with('.') || SKIPPED.contains(&name.as_str()) {
636 continue;
637 }
638 let Ok(kind) = entry.file_type() else { continue };
639 if kind.is_dir() {
640 folders.push(path.strip_prefix(root).unwrap_or(&path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/"));
641 stack.push(path);
642 } else if kind.is_file() && let Some(file_kind) = kind_of_file(&name) {
643 let relative = path.strip_prefix(root).unwrap_or(&path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
644 let mut note = if file_kind == NoteKind::Text {
645 let Ok(text) = std::fs::read_to_string(&path) else { continue };
646 read(&relative, &text)
647 } else {
648 let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem).to_string();
649 Note { path: relative, kind: file_kind, title, ..Note::default() }
650 };
651 note.modified = entry.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs() as i64);
652 notes.push(note);
653 }
654 }
655 }
656 notes.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase()));
657 folders.sort_by_key(|f| f.to_lowercase());
658 let mut vault = Vault { root: root.to_path_buf(), notes, folders };
659 vault.resolve_all();
660 vault
661 }
662
663 fn resolve_all(&mut self) {
665 let resolved: Vec<Vec<Option<String>>> = self.notes.iter().map(|note| note.links.iter().map(|l| self.resolve(note.folder(), &l.target)).collect()).collect();
666 for (note, targets) in self.notes.iter_mut().zip(resolved) {
667 for (link, target) in note.links.iter_mut().zip(targets) {
668 if let Some(path) = target {
669 link.target = uri_of(&path);
670 }
671 }
672 }
673 }
674
675 pub fn resolve(&self, folder: &str, target: &str) -> Option<String> {
677 if let Some(name) = target.strip_prefix("wiki:") {
678 return self.by_name(folder, name);
679 }
680 if let Some(path) = path_of(target) {
681 return self.notes.iter().any(|n| n.path == path).then_some(path);
682 }
683 if target.contains(':') || target.starts_with('/') {
684 return None;
685 }
686 let decoded = decode(target);
687 let joined = normalize(&if folder.is_empty() { decoded.clone() } else { format!("{folder}/{decoded}") })?;
688 let candidates = [joined.clone(), format!("{joined}.md"), format!("{joined}.txt")];
689 candidates.into_iter().find(|c| self.notes.iter().any(|n| &n.path == c))
690 }
691
692 fn by_name(&self, folder: &str, name: &str) -> Option<String> {
695 let wanted = text_stem(name.trim()).to_lowercase();
696 if wanted.is_empty() {
697 return None;
698 }
699 let matches = |n: &&Note| {
700 let path = n.path.to_lowercase();
701 let path = text_stem(&path);
702 path == wanted || path.ends_with(&format!("/{wanted}"))
703 };
704 let mut found: Vec<&Note> = self.notes.iter().filter(matches).collect();
705 if found.is_empty() {
706 found = self.notes.iter().filter(|n| n.aliases.iter().any(|a| a.to_lowercase() == wanted)).collect();
707 }
708 found.sort_by_key(|n| (n.folder() != folder, n.path.matches('/').count(), n.path.len()));
709 found.first().map(|n| n.path.clone())
710 }
711
712 pub fn note(&self, path: &str) -> Option<&Note> {
713 self.notes.iter().find(|n| n.path == path)
714 }
715
716 pub fn backlinks(&self, path: &str) -> Vec<(&Note, &NoteLink)> {
718 let uri = uri_of(path);
719 self.notes.iter().filter(|n| n.path != path).flat_map(|n| n.links.iter().filter(|l| l.target == uri).map(move |l| (n, l))).collect()
720 }
721
722 pub fn search(&self, query: &str) -> Vec<&Note> {
724 let folded = |text: &str| crate::text::fold(text).into_iter().collect::<String>();
725 let words: Vec<String> = query.split_whitespace().map(folded).collect();
726 self.notes
727 .iter()
728 .filter(|n| {
729 let haystack = folded(&format!("{} {} {}", n.title, n.path, n.tags.join(" ")));
730 words.iter().all(|w| haystack.contains(w.as_str()))
731 })
732 .collect()
733 }
734
735 pub fn with_links(&self, folder: &str, body: &str) -> String {
739 let mut out = String::with_capacity(body.len());
740 let mut fenced = false;
741 for line in body.split_inclusive('\n') {
742 let trimmed = line.trim_start();
743 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
744 fenced = !fenced;
745 }
746 if fenced || !line.contains("[[") {
747 out.push_str(line);
748 continue;
749 }
750 let mut rest = line;
751 while let Some(open) = rest.find("[[") {
752 let after = &rest[open + 2..];
753 let Some(close) = after.find("]]") else { break };
754 let inner = &after[..close];
755 let (target, shown) = inner.split_once('|').unwrap_or((inner, inner));
756 let (name, anchor) = target.split_once('#').unwrap_or((target, ""));
757 let embed = open > 0 && rest[..open].ends_with('!');
759 let start = if embed { open - 1 } else { open };
760 out.push_str(&rest[..start]);
761 match self.by_name(folder, name) {
762 Some(path) if embed && self.note(&path).is_some_and(|n| n.kind == NoteKind::Image) => {
763 out.push_str(&self.picture(&path));
764 }
765 Some(path) => {
766 let anchor = if anchor.is_empty() { String::new() } else { format!("#{}", encode_path(anchor)) };
767 out.push_str(&format!("[{}](<{}{anchor}>)", shown.trim(), uri_of(&path)));
768 }
769 None => out.push_str(&rest[start..open + 2 + close + 2]),
770 }
771 rest = &after[close + 2..];
772 }
773 out.push_str(rest);
774 }
775 out
776 }
777
778 fn picture(&self, path: &str) -> String {
780 let file = self.root.join(path);
781 let url = file_url(&file);
782 match image_size(&file) {
783 Some((width, height)) if width > 0 => {
784 let shown = width.min(560);
785 format!("<img src=\"{url}\" width=\"{shown}\" height=\"{}\">", u64::from(height) * u64::from(shown) / u64::from(width))
786 }
787 _ => format!("<img src=\"{url}\" width=\"560\">"),
788 }
789 }
790
791 pub fn file(&self, path: &str) -> Option<PathBuf> {
793 let normal = normalize(path)?;
794 Some(self.root.join(normal))
795 }
796}
797
798pub fn normalize(path: &str) -> Option<String> {
800 let mut parts: Vec<&str> = Vec::new();
801 for part in path.split(['/', '\\']) {
802 match part {
803 "" | "." => {}
804 ".." => {
805 parts.pop()?;
806 }
807 other if cfg!(windows) && other.contains(':') => return None,
810 other => parts.push(other),
811 }
812 }
813 (!parts.is_empty()).then(|| parts.join("/"))
814}
815
816pub fn file_name(title: &str) -> String {
819 let clean: String = title.trim().chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '#' | '^' | '[' | ']') || c.is_control() { '-' } else { c }).collect();
820 let clean = clean.trim_matches(['.', ' ', '-']).to_string();
821 format!("{}.md", if clean.is_empty() { "Note".to_string() } else { not_a_device(clean) })
822}
823
824pub fn write(root: &Path, path: &str, text: &str) -> Result<PathBuf, String> {
826 let relative = normalize(path).ok_or_else(|| format!("{path}: outside the notes"))?;
827 if kind_of_file(&relative) != Some(NoteKind::Text) {
828 return Err(format!("{path}: not a Markdown file"));
829 }
830 let target = root.join(&relative);
831 if let Some(parent) = target.parent() {
832 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
833 }
834 let extension = target.extension().and_then(|e| e.to_str()).unwrap_or("md");
835 let temporary = target.with_extension(format!("{extension}.new"));
836 std::fs::write(&temporary, text).map_err(|e| format!("{}: {e}", temporary.display()))?;
837 std::fs::rename(&temporary, &target).map_err(|e| format!("{}: {e}", target.display()))?;
838 Ok(target)
839}
840
841pub fn with_lock<T>(f: impl FnOnce() -> T) -> T {
849 with_lock_in(&crate::config::state_dir(), f)
850}
851
852pub fn with_lock_in<T>(state: &Path, f: impl FnOnce() -> T) -> T {
854 crate::filelock::with_lock(&state.join("notes"), f)
855}
856
857pub fn create(root: &Path, folder: &str, name: &str, text: &str) -> Result<String, String> {
862 create_in(&crate::config::state_dir(), root, folder, name, text)
863}
864
865pub fn create_in(state: &Path, root: &Path, folder: &str, name: &str, text: &str) -> Result<String, String> {
867 with_lock_in(state, || {
868 let path = free_path(root, folder, name);
869 write(root, &path, text).map(|_| path)
870 })
871}
872
873pub fn free_path(root: &Path, folder: &str, title: &str) -> String {
875 let name = file_name(title);
876 let stem = name.trim_end_matches(".md").to_string();
877 let join = |file: &str| if folder.is_empty() { file.to_string() } else { format!("{}/{file}", folder.trim_matches('/')) };
878 let mut path = join(&name);
879 let mut n = 2;
880 while root.join(&path).exists() {
881 path = join(&format!("{stem} {n}.md"));
882 n += 1;
883 }
884 path
885}
886
887pub fn new_text(title: &str, front: &[(&str, Vec<String>)], body: &str) -> String {
889 let mut out = String::new();
890 let filled: Vec<&(&str, Vec<String>)> = front.iter().filter(|(_, v)| !v.is_empty()).collect();
891 if !filled.is_empty() {
892 out.push_str("---\n");
893 for (key, values) in filled {
894 if values.len() == 1 {
895 out.push_str(&format!("{key}: {}\n", yaml_value(&values[0])));
896 } else {
897 out.push_str(&format!("{key}:\n"));
898 for value in values {
899 out.push_str(&format!(" - {}\n", yaml_value(value)));
900 }
901 }
902 }
903 out.push_str("---\n\n");
904 }
905 out.push_str(&format!("# {}\n\n", title.trim()));
906 if !body.trim().is_empty() {
907 out.push_str(body.trim_end());
908 out.push('\n');
909 }
910 out
911}
912
913pub fn add_front_link(text: &str, uri: &str) -> String {
916 let (front, start) = front_matter(text);
917 if FRONT_LINKS.iter().any(|k| front.get(*k).is_some_and(|values| values.iter().any(|v| v == uri))) {
918 return text.to_string();
919 }
920 let item = format!(" - {}", yaml_value(uri));
921 if start == 0 {
922 return format!("---\nlinks:\n{item}\n---\n\n{text}");
923 }
924 let lines: Vec<&str> = text.lines().collect();
925 let close = start - 1;
926 let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
927 let at = (1..close).find(|&i| !lines[i].starts_with([' ', '\t']) && lines[i].split_once(':').is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("links")));
928 match at {
929 Some(i) if lines[i].split_once(':').is_some_and(|(_, v)| v.trim().is_empty()) => {
931 let mut j = i + 1;
932 while j < close && lines[j].trim_start().starts_with("- ") {
933 j += 1;
934 }
935 out.insert(j, item);
936 }
937 Some(i) => {
939 let mut block = vec!["links:".to_string()];
940 block.extend(front.get("links").into_iter().flatten().map(|v| format!(" - {}", yaml_value(v))));
941 block.push(item);
942 out.splice(i..=i, block);
943 }
944 None => out.splice(close..close, ["links:".to_string(), item]).for_each(drop),
945 }
946 let mut joined = out.join("\n");
947 if text.ends_with('\n') {
948 joined.push('\n');
949 }
950 joined
951}
952
953pub fn remove_front_link(text: &str, same: impl Fn(&str) -> bool) -> (String, bool) {
956 let (_, start) = front_matter(text);
957 if start == 0 {
958 return (text.to_string(), false);
959 }
960 let close = start - 1;
961 let lines: Vec<&str> = text.lines().collect();
962 let mut kept: Vec<String> = vec![lines[0].to_string()];
963 let mut removed = false;
964 let mut key = String::new();
965 for line in &lines[1..close] {
966 let trimmed = line.trim();
967 if let Some(item) = trimmed.strip_prefix("- ") {
968 if FRONT_LINKS.contains(&key.as_str()) && same(&unquote(item)) {
969 removed = true;
970 continue;
971 }
972 } else if let Some((k, value)) = trimmed.split_once(':') {
973 key = k.trim().to_lowercase();
974 let value = value.trim();
975 if FRONT_LINKS.contains(&key.as_str()) && !value.is_empty() {
976 if let Some(list) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
977 let all: Vec<String> = list.split(',').map(unquote).filter(|v| !v.is_empty()).collect();
978 let left: Vec<String> = all.iter().filter(|v| !same(v)).cloned().collect();
979 if left.len() != all.len() {
980 removed = true;
981 if !left.is_empty() {
982 kept.push(format!("{key}: [{}]", left.iter().map(|v| yaml_value(v)).collect::<Vec<_>>().join(", ")));
983 }
984 continue;
985 }
986 } else if same(&unquote(value)) {
987 removed = true;
988 continue;
989 }
990 }
991 }
992 kept.push(line.to_string());
993 }
994 if !removed {
995 return (text.to_string(), false);
996 }
997 let mut tidy: Vec<String> = Vec::new();
999 for (i, line) in kept.iter().enumerate() {
1000 let lone = line.trim_end().strip_suffix(':').is_some_and(|k| FRONT_LINKS.contains(&k.trim().to_lowercase().as_str()) && !line.starts_with([' ', '\t']));
1001 if lone && !kept.get(i + 1).is_some_and(|next| next.trim_start().starts_with("- ")) {
1002 continue;
1003 }
1004 tidy.push(line.clone());
1005 }
1006 let rest: Vec<&str> = lines[close..].to_vec();
1007 let mut out: Vec<String> = if tidy.len() == 1 {
1009 let after: Vec<String> = rest[1..].iter().map(|l| l.to_string()).collect();
1010 after.into_iter().skip_while(|l| l.trim().is_empty()).collect()
1011 } else {
1012 tidy.into_iter().chain(rest.iter().map(|l| l.to_string())).collect()
1013 };
1014 if out.is_empty() {
1015 out.push(String::new());
1016 }
1017 let mut joined = out.join("\n");
1018 if text.ends_with('\n') && !joined.ends_with('\n') {
1019 joined.push('\n');
1020 }
1021 (joined, true)
1022}
1023
1024fn yaml_value(value: &str) -> String {
1027 let value: String = value.chars().filter(|c| !c.is_control()).collect();
1028 let value = value.as_str();
1029 let plain = !value.is_empty() && !value.starts_with([' ', '-', '[', '{', '#', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`']) && !value.contains(": ") && !value.contains(" #") && !value.ends_with(':');
1030 if plain { value.to_string() } else { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use super::*;
1036
1037 #[test]
1038 fn folders_made_renamed_taken_out() {
1039 let root = std::env::temp_dir().join(format!("sioul-folders-{}", std::process::id()));
1040 let _ = std::fs::remove_dir_all(&root);
1041 std::fs::create_dir_all(root.join("admin")).unwrap();
1042 std::fs::write(root.join("admin/lease.md"), "# Lease\n").unwrap();
1043 std::fs::write(root.join("index.md"), "See [[admin/lease]] and sioul:note/admin/lease.md.\n").unwrap();
1044 assert_eq!(make_folder(&root, "admin", "letters").unwrap(), "admin/letters");
1045 assert!(make_folder(&root, "", "admin").is_err(), "taken");
1046 let vault = Vault::open(&root);
1047 assert_eq!(vault.folders, vec!["admin".to_string(), "admin/letters".into()]);
1048 let (new, moved) = rename_folder(&vault, "admin", "papers").unwrap();
1049 assert_eq!((new.as_str(), moved), ("papers", vec![("admin/lease.md".to_string(), "papers/lease.md".to_string())]));
1050 let index = std::fs::read_to_string(root.join("index.md")).unwrap();
1051 assert!(index.contains("[[papers/lease]]") && index.contains(&uri_of("papers/lease.md")), "{index}");
1052 assert!(remove_folder(&root, "papers").is_err(), "it holds a note");
1053 remove_folder(&root, "papers/letters").unwrap();
1054 let _ = std::fs::remove_dir_all(&root);
1055 }
1056
1057 #[test]
1058 fn a_note_renamed_takes_its_links_along() {
1059 let dir = std::env::temp_dir().join(format!("sioul-rename-{}", std::process::id()));
1060 std::fs::create_dir_all(dir.join("admin")).unwrap();
1061 std::fs::write(dir.join("admin/lease.md"), "# Lease\n").unwrap();
1062 std::fs::write(dir.join("plan.md"), "See [[lease]], [[lease|the lease]], [[lease#signed]] and [the file](admin/lease.md).\n").unwrap();
1063 std::fs::write(dir.join("admin/other.md"), "Also <sioul:note/admin/lease.md> and [here](lease.md).\n").unwrap();
1064 let vault = Vault::open(&dir);
1065 let (path, changed) = rename(&vault, "admin/lease.md", "Lease 2026").unwrap();
1066 assert_eq!(path, "admin/Lease 2026.md");
1067 assert_eq!(changed.len(), 2);
1068 let read = |p: &str| std::fs::read_to_string(dir.join(p)).unwrap();
1069 assert_eq!(read("plan.md"), "See [[Lease 2026]], [[Lease 2026|the lease]], [[Lease 2026#signed]] and [the file](admin/Lease%202026.md).\n");
1070 assert_eq!(read("admin/other.md"), "Also <sioul:note/admin/Lease%202026.md> and [here](Lease%202026.md).\n");
1071 let trashed = trash(&dir, &path).unwrap();
1073 assert_eq!(trashed, ".trash/admin/Lease 2026.md");
1074 assert!(Vault::open(&dir).note(&path).is_none());
1075 assert!(untrash(&dir, "../elsewhere.md", "back.md").is_err(), "only from the vault's trash");
1076 untrash(&dir, &trashed, &path).unwrap();
1077 assert!(Vault::open(&dir).note(&path).is_some());
1078 std::fs::remove_dir_all(&dir).unwrap();
1079 }
1080
1081 #[test]
1082 fn pictures_pdfs_and_sounds_are_notes_too() {
1083 let dir = std::env::temp_dir().join(format!("sioul-media-{}", std::process::id()));
1084 std::fs::create_dir_all(dir.join("memos")).unwrap();
1085 std::fs::write(dir.join("plan.md"), "# Plan\n\n![[scan.png]] and [[memo.ogg]]\n").unwrap();
1086 let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
1088 png.extend(1200u32.to_be_bytes());
1089 png.extend(300u32.to_be_bytes());
1090 png.extend([8, 2, 0, 0, 0]);
1091 std::fs::write(dir.join("scan.png"), &png).unwrap();
1092 std::fs::write(dir.join("memos/memo.ogg"), b"OggS").unwrap();
1093 std::fs::write(dir.join("form.pdf"), b"%PDF-1.7").unwrap();
1094 std::fs::write(dir.join("shopping.txt"), "Shopping\n\n- [ ] bread\n").unwrap();
1096 std::fs::write(dir.join("data.csv"), "left out").unwrap();
1097 let vault = Vault::open(&dir);
1098 let kinds: Vec<(&str, NoteKind)> = vault.notes.iter().map(|n| (n.path.as_str(), n.kind)).collect();
1099 assert_eq!(kinds, vec![("form.pdf", NoteKind::Pdf), ("memos/memo.ogg", NoteKind::Audio), ("plan.md", NoteKind::Text), ("scan.png", NoteKind::Image), ("shopping.txt", NoteKind::Text)]);
1100 assert_eq!(vault.note("shopping.txt").map(|n| n.stem()), Some("shopping"));
1101 assert_eq!(vault.resolve("", "wiki:shopping").as_deref(), Some("shopping.txt"));
1102 assert!(write(&dir, "shopping.txt", "Shopping\n\n- [x] bread\n").is_ok(), "a .txt note is written back as it is");
1103 assert!(write(&dir, "data.csv", "x").is_err());
1104 assert_eq!(vault.note("memos/memo.ogg").map(|n| n.title.as_str()), Some("memo"));
1105 assert_eq!(image_size(&dir.join("scan.png")), Some((1200, 300)));
1106 let body = vault.with_links("", "![[scan.png]] and [[memo.ogg]]");
1107 assert!(body.contains("width=\"560\" height=\"140\""), "{body}");
1108 assert!(body.contains("[memo.ogg](<sioul:note/memos/memo.ogg>)"), "{body}");
1109 std::fs::remove_dir_all(&dir).unwrap();
1110 }
1111
1112 #[test]
1113 fn front_links_come_and_go() {
1114 let bare = "# Plan\n\nText.\n";
1115 let with = add_front_link(bare, "sioul:task/abc");
1116 assert_eq!(with, "---\nlinks:\n - sioul:task/abc\n---\n\n# Plan\n\nText.\n");
1117 assert_eq!(read("plan.md", &with).front["links"], vec!["sioul:task/abc"]);
1118 let two = add_front_link(&with, "mid:x@example.org");
1119 assert_eq!(read("plan.md", &two).front["links"], vec!["sioul:task/abc", "mid:x@example.org"]);
1120 assert_eq!(add_front_link(&two, "mid:x@example.org"), two, "a link is added once");
1121 let inline = "---\ntags: [a]\nlinks: [sioul:task/abc]\n---\n# T\n";
1122 let grown = add_front_link(inline, "sioul:event/e");
1123 assert_eq!(read("t.md", &grown).front["links"], vec!["sioul:task/abc", "sioul:event/e"]);
1124 assert!(grown.contains("tags: [a]"), "{grown}");
1125 let (less, removed) = remove_front_link(&two, |v| v == "sioul:task/abc");
1126 assert!(removed);
1127 assert_eq!(read("plan.md", &less).front["links"], vec!["mid:x@example.org"]);
1128 let (none, _) = remove_front_link(&less, |v| v == "mid:x@example.org");
1129 assert_eq!(none, bare, "an empty front matter goes");
1130 let (kept, removed) = remove_front_link("---\ntags: [a]\nevent: sioul:event/e\n---\n# T\n", |v| v == "sioul:event/e");
1131 assert!(removed);
1132 assert_eq!(kept, "---\ntags: [a]\n---\n# T\n");
1133 }
1134
1135 const LETTERS: &str = "---\ntitle: Outbox\ntags: [admin, letters]\naliases:\n - Letters\ntask: sioul:task/form\n---\n\n# Every letter\n\n\
1136 See [the plan](../plan/plan.md#october) and [[Overview]], or [[plan#Money|money]].\n\
1137 From <mid:abc@caf.example>, about #housing and #health/care, not #1453 nor `#code`.\n\
1138 - [ ] Call the CAF\n- [x] Print the form\n```\n[[not a link]]\n```\n";
1139
1140 fn vault() -> Vault {
1141 let mut notes = vec![
1142 read("admin/letters.md", LETTERS),
1143 read("plan/plan.md", "# The plan\n\nBack to [[Letters]].\n"),
1144 read("Overview.md", "# Overview\n"),
1145 read("old/Overview.md", "# Old overview\n"),
1146 ];
1147 notes.sort_by(|a, b| a.path.cmp(&b.path));
1148 let mut vault = Vault { root: PathBuf::from("/vault"), notes, folders: Vec::new() };
1149 vault.resolve_all();
1150 vault
1151 }
1152
1153 #[test]
1154 fn reads_a_note() {
1155 let note = read("admin/letters.md", LETTERS);
1156 assert_eq!(note.title, "Outbox");
1157 assert_eq!(note.tags, vec!["admin", "letters", "housing", "health/care"]);
1158 assert_eq!(note.aliases, vec!["Letters"]);
1159 assert_eq!(note.checkboxes, vec![Checkbox { line: 13, done: false, text: "Call the CAF".into() }, Checkbox { line: 14, done: true, text: "Print the form".into() }]);
1160 let targets: Vec<&str> = note.links.iter().map(|l| l.target.as_str()).collect();
1161 assert_eq!(targets, vec!["sioul:task/form", "wiki:Overview", "wiki:plan", "../plan/plan.md", "mid:abc@caf.example"]);
1162 assert_eq!(note.links[3].anchor, "october");
1163 assert_eq!((note.links[2].anchor.as_str(), note.links[2].text.as_str()), ("Money", "money"));
1164 }
1165
1166 #[test]
1167 fn links_resolve_and_come_back() {
1168 let vault = vault();
1169 let letters = vault.note("admin/letters.md").unwrap();
1170 let targets: Vec<&str> = letters.links.iter().map(|l| l.target.as_str()).collect();
1171 assert_eq!(targets, vec!["sioul:task/form", "sioul:note/Overview.md", "sioul:note/plan/plan.md", "sioul:note/plan/plan.md", "mid:abc@caf.example"]);
1173 let back: Vec<&str> = vault.backlinks("admin/letters.md").iter().map(|(n, _)| n.path.as_str()).collect();
1175 assert_eq!(back, vec!["plan/plan.md"]);
1176 assert_eq!(vault.backlinks("plan/plan.md").len(), 2);
1177 assert_eq!(vault.search("outbox ADMIN").len(), 1);
1178 assert_eq!(vault.with_links("admin", "See [[plan#Money|money]] and [[nowhere]].\n"), "See [money](<sioul:note/plan/plan.md#Money>) and [[nowhere]].\n");
1179 assert_eq!(normalize("a/../../b"), None);
1180 }
1181
1182 #[test]
1183 fn new_notes() {
1184 let text = new_text("Meeting: CAF", &[("event", vec!["sioul:event/abc".into()]), ("tags", vec![])], "Notes here.");
1185 assert_eq!(text, "---\nevent: sioul:event/abc\n---\n\n# Meeting: CAF\n\nNotes here.\n");
1186 let note = read("Meeting- CAF.md", &text);
1187 assert_eq!((note.title.as_str(), note.links[0].target.as_str()), ("Meeting: CAF", "sioul:event/abc"));
1188 assert_eq!(file_name("Meeting: CAF / 5 Oct"), "Meeting- CAF - 5 Oct.md");
1189 assert_eq!(path_of(&uri_of("admin/my letters.md")).as_deref(), Some("admin/my letters.md"));
1190 assert_eq!((file_name("Nul"), file_name("com1"), file_name("Console")), ("Nul_.md".to_string(), "com1_.md".to_string(), "Console.md".to_string()));
1192 assert_eq!(file_name("aux.txt"), "aux_.txt.md", "the name before its first dot is what Windows reads");
1193 #[cfg(windows)]
1194 assert_eq!(normalize("C:/Windows/win.ini"), None, "another drive is outside the notes");
1195 assert_eq!(add_front_link("# T\n", "sioul:task/a\ntitle: x"), "---\nlinks:\n - \"sioul:task/atitle: x\"\n---\n\n# T\n");
1197 assert_eq!(uri_of("Santé/impôts 2025#1.md"), "sioul:note/Santé/impôts%202025%231.md");
1199 assert_eq!(path_of(&uri_of("Santé/impôts 2025#1.md")).as_deref(), Some("Santé/impôts 2025#1.md"));
1200 assert_eq!(path_of("sioul:note/Sant\u{c3}\u{a9}.md").as_deref(), Some("Santé.md"));
1201 #[cfg(unix)]
1203 assert_eq!(file_url(Path::new("/tmp/a b/x#1%.png")), "file:///tmp/a%20b/x%231%25.png");
1204 #[cfg(windows)]
1205 assert_eq!(file_url(Path::new(r"C:\Users\a b\x#1.png")), "file:///C:/Users/a%20b/x%231.png");
1206 }
1207}