Skip to main content

sioul_core/
notes.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Notes: a folder of Markdown files, read the way Obsidian reads a vault.
5//!
6//! - **Links between notes**: `[[wikilinks]]` (`[[note]]`, `[[note#heading]]`,
7//!   `[[note|shown as]]`, found by name anywhere in the vault) and ordinary
8//!   Markdown links (`[text](../admin/letters.md#2)`, relative to the note).
9//! - **Links to everything else**: `mid:` for a message (RFC 2392), `sioul:`
10//!   for a task, an event, a contact, a draft (`links`), web addresses, files.
11//! - **Links that come back**: every note that links to this one.
12//! - **Tags**: `#tag` in the text, and `tags:` in the front matter.
13//! - **Front matter**: the YAML block at the top, read for `title`, `tags`,
14//!   `aliases` and links (`task:`, `event:`, `mail:` …).
15//! - **Checkboxes**: `- [ ] line`, ready to become tasks.
16//!
17//! The folder is yours (the notes folder, docs/notes-folder.md). Sioul reads it,
18//! and writes only the notes you write or make from it.
19
20use serde::Serialize;
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24/// Folders never read: tools' own, and hidden ones.
25const SKIPPED: &[&str] = &["node_modules", "target", ".git", ".obsidian", ".trash"];
26
27/// A link found in a note.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct NoteLink {
30    /// Another note: `sioul:note/<path>`; else the address as written (`mid:…`, `sioul:task/…`, `https://…`).
31    pub target: String,
32    /// "#heading" in the target, without the "#".
33    pub anchor: String,
34    /// The link's text.
35    pub text: String,
36    /// Its line, from 1.
37    pub line: usize,
38}
39
40/// A checkbox line: `- [ ] call the CAF`.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct Checkbox {
43    pub line: usize,
44    pub done: bool,
45    pub text: String,
46}
47
48/// What a file of the vault is: a Markdown note, or a picture, a PDF, a
49/// sound (an audio memo) kept beside the notes, read here and linked the same way.
50#[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
60/// The kind of a file by its extension; None for files the vault leaves out.
61/// Text notes are Markdown, whether named `.md` (Obsidian's way) or `.txt`
62/// (Nextcloud Notes' default, Markdown all the same).
63pub 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
74/// A text note's name without its extension: "letters.md" and "letters.txt"
75/// both give "letters"; any other name is given back as it is.
76pub 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/// One note, as the index knows it.
87#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
88pub struct Note {
89    /// Its place in the vault, with "/": "admin/letters.md".
90    pub path: String,
91    /// Markdown, or a picture, a PDF, a sound.
92    pub kind: NoteKind,
93    /// The front matter's title, else the first heading, else the file's name.
94    pub title: String,
95    pub tags: Vec<String>,
96    pub aliases: Vec<String>,
97    pub links: Vec<NoteLink>,
98    pub checkboxes: Vec<Checkbox>,
99    /// The front matter, key by key, each value as a list.
100    pub front: BTreeMap<String, Vec<String>>,
101    /// Last changed, Unix seconds.
102    pub modified: i64,
103}
104
105impl Note {
106    /// Its address for links: "sioul:note/admin/letters.md".
107    pub fn uri(&self) -> String {
108        uri_of(&self.path)
109    }
110
111    /// Its file name without ".md" or ".txt".
112    pub fn stem(&self) -> &str {
113        let name = self.path.rsplit('/').next().unwrap_or(&self.path);
114        text_stem(name)
115    }
116
117    /// Its folder in the vault, "" at the root.
118    pub fn folder(&self) -> &str {
119        self.path.rsplit_once('/').map_or("", |(folder, _)| folder)
120    }
121}
122
123/// Moves a note (or a picture, a PDF, a sound) into the vault's `.trash`,
124/// as Obsidian does: out of sight, never lost. Returns where it went.
125pub 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
142/// Puts back a note from the trash, where it was.
143pub 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    // Only from the vault's own trash.
146    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
153/// A note renamed: its file gets `name` (its extension kept), in the same
154/// folder, and the notes that link to it follow: `[[old]]`, `![[old.png]]`,
155/// Markdown links by path, `sioul:note/` addresses. Returns its new path and
156/// the notes changed.
157pub 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    // Nothing hidden: a name starting with a dot would take the note out of the list.
161    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    // The ways other notes name it, and what they say now.
178    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
204/// A folder's name made safe on every system: no slash, nothing hidden.
205fn 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
211/// A name Windows keeps for a device ("CON", "NUL", "AUX", "PRN", "COM1",
212/// "LPT1"…, whatever follows its first dot) taken out of its way with a "_"
213/// before that dot: "Nul.md" would write to no file there, "aux.txt.md" too
214/// ("aux_.txt.md" does not), and notes travel between systems.
215fn 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
222/// A new folder in `parent` ("" for the top); returns its path.
223pub 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
234/// A folder renamed in place, its notes with it; the notes that name them by
235/// their path follow (`[[folder/note]]`, Markdown links, `sioul:note/`
236/// addresses). Returns its new path and each note moved, from and to.
237pub 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
274/// An empty folder taken out; one holding anything stays, with why.
275pub 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
285/// The path from a note's folder to another note: "../admin/lease.md".
286fn 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
295/// A picture's width and height from its first bytes: PNG, GIF, JPEG, WebP.
296pub 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        // The first frame header (SOF0 to SOF15, but not DHT, JPG or DAC) holds the size.
325        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
341/// A note's address for links.
342pub fn uri_of(path: &str) -> String {
343    format!("sioul:note/{}", encode_path(path))
344}
345
346/// The vault path of a note's address, when it is one.
347pub 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    // Addresses written before October 2026 carried each byte of an accented
351    // name as a letter of its own ("Santé" for "Santé"): read as meant.
352    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
356/// Spaces and the few characters a URI cannot hold, percent-encoded; "/" and
357/// the letters of every language kept as they are (an IRI, RFC 3987), so that
358/// `decode` gives the same path back ("Santé/impôts.md").
359pub 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
373/// A file's address for the window (a picture, a PDF): "file:///home/…", and
374/// "file:///C:/Users/…" on Windows, with "/" between its parts and what an
375/// address cannot hold ("#", "?", "%", spaces) percent-encoded.
376pub fn file_url(path: &Path) -> String {
377    let text = path.to_string_lossy();
378    // Windows writes "C:\Users\…" (or "\\?\C:\…"); elsewhere "\" belongs to a name.
379    let text = if cfg!(windows) { text.replace('\\', "/").trim_start_matches("//?/").to_string() } else { text.to_string() };
380    match text {
381        // A share of another computer: "file://server/share/…".
382        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
388/// Percent-decoding, as links in Markdown write spaces ("my%20note.md").
389pub 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
407/// The front matter: the lines between a first "---" and the next, read as
408/// simple YAML (`key: value`, `key: [a, b]`, `key:` then `- item` lines).
409/// Returns it, and the line where the text starts.
410pub 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
447/// Front matter keys whose values are links. `case: housing`, a project by
448/// its id alone, is how notes written before the name changed say it; a
449/// `project:` key is left alone, as many vaults use it for their own ends.
450const FRONT_LINKS: &[&str] = &["task", "tasks", "event", "events", "mail", "mails", "contact", "contacts", "draft", "drafts", "links", "related", "case"];
451
452/// Reads one note's text: its title, tags, links and checkboxes. Links are
453/// kept as written; `Vault` resolves those that point at notes.
454pub 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
501/// `- [ ] text`, `* [x] text`, `1. [ ] text`.
502fn 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
517/// A line with its `inline code` blanked, so links and tags inside are not taken.
518fn 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
532/// Wikilinks and Markdown links in a line.
533fn links_in(line: &str, number: usize, out: &mut Vec<NoteLink>) {
534    // [[target#anchor|text]], and ![[embedded]].
535    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    // [text](target "title"), [text](<target with spaces>).
548    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    // Bare addresses written as they are: <mid:…>, sioul:…
590    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
598/// `#tag` and `#nested/tag`: after a space or at the start, not all digits, not a heading.
599fn 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/// Every note of a vault, with what links where.
615#[derive(Debug, Clone, Default)]
616pub struct Vault {
617    pub root: PathBuf,
618    pub notes: Vec<Note>,
619    /// Every folder, empty ones too: "admin", "admin/letters".
620    pub folders: Vec<String>,
621}
622
623impl Vault {
624    /// Reads every Markdown file under `root`, and lists the pictures, PDFs
625    /// and sounds beside them; hidden folders and tools' own are skipped.
626    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    /// Links to notes, made into vault paths: "wiki:letters" and "../admin/letters.md" → "sioul:note/admin/letters.md".
664    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    /// The vault path a link from a note in `folder` points at, when it is a note.
676    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    /// Obsidian's rule for `[[name]]`: a path that ends with it, the same
693    /// folder first, then the shortest path; an alias otherwise.
694    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    /// The notes that link to this one, with the line of each link.
717    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    /// The notes whose title, path or tags hold every word of `query`, case and accents ignored.
723    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    /// A note's Markdown with its `[[wikilinks]]` made ordinary links to the
736    /// notes they find (`sioul:note/…`), so they can be rendered and followed;
737    /// one that finds nothing stays as typed. Code is left alone.
738    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                // An embed (![[…]]): a picture shows, sized to the page; anything else is a link.
758                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    /// A picture of the vault as HTML, at most 560 pixels wide.
779    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    /// Where a note's file is.
792    pub fn file(&self, path: &str) -> Option<PathBuf> {
793        let normal = normalize(path)?;
794        Some(self.root.join(normal))
795    }
796}
797
798/// "a/./b/../c.md" → "a/c.md"; None when it leaves the vault.
799pub 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            // On Windows "C:" leads to another drive when joined ("sioul:note/C:/…"
808            // from someone else's task), and "note.md:x" to a hidden stream.
809            other if cfg!(windows) && other.contains(':') => return None,
810            other => parts.push(other),
811        }
812    }
813    (!parts.is_empty()).then(|| parts.join("/"))
814}
815
816/// A file name for a new note from its title: the title itself, without what
817/// file systems refuse.
818pub 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
824/// Writes a note next to its place, then moves it in.
825pub 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
841/// One writer at a time among Sioul's own writers of notes: the window and
842/// an agent (`sioul mcp`) each read a note again, change it and write it with
843/// this held, so that neither writes an older copy over the other's change,
844/// and two new notes never take one name. An advisory lock (`filelock`) on
845/// one hidden file of Sioul's state folder (`.notes.lock`), not one beside
846/// each note: the notes folder is yours, and a sync app or another editor
847/// would carry or show a lock file in each of its folders.
848pub fn with_lock<T>(f: impl FnOnce() -> T) -> T {
849    with_lock_in(&crate::config::state_dir(), f)
850}
851
852/// `with_lock`, its lock in `state` (the tests' own folder).
853pub fn with_lock_in<T>(state: &Path, f: impl FnOnce() -> T) -> T {
854    crate::filelock::with_lock(&state.join("notes"), f)
855}
856
857/// A new note in `folder`, its file named after `name` (`free_path`), holding
858/// `text`; the free name found and taken under the notes' lock (`with_lock`):
859/// two writers making "Plan" at once make "Plan.md" and "Plan 2.md", and
860/// neither is written over. Returns its path in the notes.
861pub 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
865/// `create`, the lock in `state`.
866pub 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
873/// A free path for a new note in `folder`: "Title.md", else "Title 2.md"…
874pub 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
887/// A new note's text: front matter with its links, a title, then the body.
888pub 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
913/// The text with `uri` among its front matter's `links`; the front matter is
914/// made when there is none. Everything else stays as written.
915pub 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        // `links:` then its items: one more after the last.
930        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        // `links: a` or `links: [a, b]`: written again as a list.
938        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
953/// The text without the front matter links `same` picks, and whether there
954/// were any. A link written in the text itself is left: it is the note's words.
955pub 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    // A link key left without items goes too.
998    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    // Nothing left in the front matter: it goes, with the empty line after it.
1008    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
1024/// A YAML scalar, quoted when it would not read back as written; on one
1025/// line, as a line break would start another key of the front matter.
1026fn 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        // To the trash, out of the notes, and back.
1072        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        // A PNG's first bytes: 1200 × 300.
1087        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        // Nextcloud Notes writes `.txt` by default: Markdown all the same.
1095        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        // [[Overview]]: the shortest path wins over old/Overview.md.
1172        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        // [[Letters]] finds the note by its alias.
1174        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        // Names Windows keeps for its devices: "Nul.md" would be written nowhere there.
1191        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        // A line break in a link stays out of the front matter.
1196        assert_eq!(add_front_link("# T\n", "sioul:task/a\ntitle: x"), "---\nlinks:\n  - \"sioul:task/atitle: x\"\n---\n\n# T\n");
1197        // Accented names keep their address and come back as they were; so do addresses written before ("Santé").
1198        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        // Files as addresses for the window, on every system.
1202        #[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}