//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of //! what the server computes on save. Pure string scanning (no regex dependency), //! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts): //! //! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to //! compute backlinks at query time (links are derived, never stored/synced). //! - `#tag`: `#` at a word boundary followed by tag characters (letter first). //! On save these become labels attached with `via_tag = true`. //! //! Both dedupe case-insensitively, preserving first-seen order. /// Extract the trimmed inner text of every `[[wiki-link]]` in `body`. pub fn extract_links(body: &str) -> Vec { let bytes = body.as_bytes(); let mut out: Vec = Vec::new(); let mut i = 0; while i + 1 < bytes.len() { if bytes[i] == b'[' && bytes[i + 1] == b'[' { if let Some(rel) = body[i + 2..].find("]]") { let inner = &body[i + 2..i + 2 + rel]; // Mirror the frontend's `[^[\]]+`: no stray brackets inside. if !inner.contains('[') && !inner.contains(']') { let t = inner.trim(); if !t.is_empty() { push_unique(&mut out, t); } } i += 2 + rel + 2; continue; } } i += 1; } out } /// Extract every `#tag` name (without the leading `#`) from `body`. pub fn extract_tags(body: &str) -> Vec { let chars: Vec = body.chars().collect(); let mut out: Vec = Vec::new(); let mut i = 0; while i < chars.len() { if chars[i] == '#' { let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#'); // A tag must start with a letter (so "#1" or a bare "#" is not a tag). if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() { let mut j = i + 1; while j < chars.len() && is_tag_char(chars[j]) { j += 1; } let tag: String = chars[i + 1..j].iter().collect(); push_unique(&mut out, &tag); i = j; continue; } } i += 1; } out } fn is_tag_char(c: char) -> bool { c.is_alphanumeric() || c == '_' || c == '-' } fn push_unique(out: &mut Vec, candidate: &str) { if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) { out.push(candidate.to_string()); } } #[cfg(test)] mod tests { use super::*; #[test] fn links_basic_and_trim() { assert_eq!(extract_links("see [[ Alpha ]] and [[Beta]]"), vec!["Alpha", "Beta"]); } #[test] fn links_dedupe_case_insensitive_first_seen() { assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]); } #[test] fn links_ignore_malformed_and_nested_brackets() { assert_eq!(extract_links("[[a[b]] [[]] [ [x] ] plain"), Vec::::new()); assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]); } #[test] fn tags_basic() { assert_eq!(extract_tags("a #todo and #Work-item_2 here"), vec!["todo", "Work-item_2"]); } #[test] fn tags_require_letter_start_and_boundary() { // "#1" (digit) and an in-word "#" (email-ish) are not tags. assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]); } #[test] fn tags_dedupe_case_insensitive() { assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]); } #[test] fn empty_body() { assert!(extract_links("").is_empty()); assert!(extract_tags("").is_empty()); } }