1use regex::Regex;
7
8pub fn norm_new_lines(s: &str) -> String {
10 s.replace("\r\n", "\n").replace('\r', "\n")
11}
12
13pub fn first_word(s: &str) -> &str {
15 s.split_whitespace().next().unwrap_or(s)
16}
17
18pub fn similar(a: &str, b: &str) -> f64 {
20 if a.is_empty() || b.is_empty() {
21 return 0.0;
22 }
23 let a_lower = a.to_lowercase();
24 let b_lower = b.to_lowercase();
25 if a_lower == b_lower {
26 return 100.0;
27 }
28 let max_len = a_lower.len().max(b_lower.len());
29 if max_len == 0 {
30 return 100.0;
31 }
32 let distance = levenshtein(&a_lower, &b_lower);
33 ((max_len - distance) as f64 / max_len as f64) * 100.0
34}
35
36#[allow(clippy::needless_range_loop)]
38pub fn levenshtein(a: &str, b: &str) -> usize {
39 let len_a = a.len();
40 let len_b = b.len();
41 if len_a == 0 {
42 return len_b;
43 }
44 if len_b == 0 {
45 return len_a;
46 }
47
48 let mut matrix = vec![vec![0usize; len_b + 1]; len_a + 1];
49 for i in 0..=len_a {
50 matrix[i][0] = i;
51 }
52 for j in 0..=len_b {
53 matrix[0][j] = j;
54 }
55
56 for i in 1..=len_a {
57 for j in 1..=len_b {
58 let cost = if a.as_bytes()[i - 1] == b.as_bytes()[j - 1] {
59 0
60 } else {
61 1
62 };
63 matrix[i][j] = (matrix[i - 1][j] + 1)
64 .min(matrix[i][j - 1] + 1)
65 .min(matrix[i - 1][j - 1] + cost);
66 }
67 }
68 matrix[len_a][len_b]
69}
70
71pub fn truncate(s: &str, max_len: usize) -> String {
73 if s.len() <= max_len {
74 s.to_string()
75 } else {
76 format!("{}...", &s[..max_len.saturating_sub(3)])
77 }
78}
79
80pub fn ucfirst(s: &str) -> String {
89 let mut chars = s.chars();
90 match chars.next() {
91 Some(first) => first.to_uppercase().chain(chars).collect(),
92 None => String::new(),
93 }
94}
95
96pub fn lcfirst(s: &str) -> String {
104 let mut chars = s.chars();
105 match chars.next() {
106 Some(first) => first.to_lowercase().chain(chars).collect(),
107 None => String::new(),
108 }
109}
110
111pub fn substr(input: &str, start: usize, length: usize) -> String {
123 let runes: Vec<char> = input.chars().collect();
124 if start >= runes.len() {
125 return String::new();
126 }
127 let end = (start + length).min(runes.len());
128 runes[start..end].iter().collect()
129}
130
131pub fn is_multiline(text: &str) -> bool {
139 let text = norm_new_lines(text);
140 text.lines().count() > 1
141}
142
143pub fn split_text_into_chunks(text: &str, max_len: usize) -> Vec<String> {
157 let text = text.trim();
158
159 if max_len == 0 {
160 return vec![text.to_string()];
161 }
162
163 let mut chunks = Vec::new();
164 let mut runes: Vec<char> = text.chars().collect();
165
166 while runes.len() > max_len {
167 let window = &runes[..max_len];
168
169 let mut split_index = None;
171 for i in (0..window.len()).rev() {
172 if window[i] == '\n' {
173 split_index = Some(i);
174 break;
175 }
176 }
177
178 if split_index.is_none() {
180 for i in (0..window.len()).rev() {
181 if window[i] == ' ' {
182 split_index = Some(i);
183 break;
184 }
185 }
186 }
187
188 let split_index = split_index.unwrap_or(max_len);
190
191 let chunk: String = runes[..split_index].iter().collect();
192 let chunk = chunk.trim();
193 if !chunk.is_empty() {
194 chunks.push(chunk.to_string());
195 }
196
197 let remainder: String = runes[split_index..].iter().collect();
198 runes = remainder.trim().chars().collect();
199 }
200
201 let remainder: String = runes.iter().collect();
203 let remainder = remainder.trim();
204 if !remainder.is_empty() {
205 chunks.push(remainder.to_string());
206 }
207
208 chunks
209}
210
211const EMOJI_STRIP_PREFIXES: &[&str] = &["WRK ", "UA ", "US ", "CY ", "HOB ", "SRB ", "PL "];
213
214pub fn emoji_prefix(emoji: &str, s: &str) -> String {
224 let mut s = s.to_string();
225 for prefix in EMOJI_STRIP_PREFIXES {
226 s = s.trim_start_matches(prefix).to_string();
227 }
228 if emoji.is_empty() {
229 return s;
230 }
231 format!("{emoji} {s}")
232}
233
234pub fn has_image(msg: &str) -> bool {
236 Regex::new(r"!\[.*?\]\(.*?\)")
237 .expect("valid regex literal")
238 .is_match(msg)
239}
240
241pub fn strip_chat_timestamp(s: &str) -> String {
243 Regex::new(r"^`\d{2}:\d{2}` ")
244 .expect("valid regex literal")
245 .replace(s, "")
246 .to_string()
247}
248
249pub fn extract_markdown_links(content: &str) -> Vec<(String, String)> {
253 let re = Regex::new(r"\[([^\]]*)\]\(([^)]+)\)").expect("valid regex literal");
254 re.captures_iter(content)
255 .filter_map(|cap| {
256 let text = cap.get(1)?.as_str().to_string();
257 let path = cap.get(2)?.as_str().to_string();
258 if path.starts_with("http://") || path.starts_with("https://") {
260 return None;
261 }
262 Some((text, path))
263 })
264 .collect()
265}
266
267pub fn rewrite_link_targets(content: &str, old_target: &str, new_target: &str) -> (String, usize) {
277 if old_target == new_target || old_target.is_empty() {
278 return (content.to_string(), 0);
279 }
280 let pattern = format!(r"\]\({}\)", regex::escape(old_target));
283 let re = match Regex::new(&pattern) {
284 Ok(r) => r,
285 Err(_) => return (content.to_string(), 0),
286 };
287 let replacement = format!("]({new_target})");
288 let count = re.find_iter(content).count();
289 (
290 re.replace_all(content, replacement.as_str()).to_string(),
291 count,
292 )
293}
294
295pub fn extract_wikilinks(content: &str) -> Vec<(String, Option<String>)> {
302 let body = crate::backlinks::strip_frontmatter(content);
303 let re = match Regex::new(r"\[\[([^\[\]\n|]+)(?:\|([^\[\]\n]+))*\]\]") {
304 Ok(r) => r,
305 Err(_) => return Vec::new(),
306 };
307 re.captures_iter(body)
308 .filter_map(|cap| {
309 let target = cap.get(1)?.as_str().trim().to_string();
310 if target.is_empty() {
311 return None;
312 }
313 let alias = cap.get(2).map(|m| m.as_str().trim().to_string());
314 Some((target, alias))
315 })
316 .collect()
317}
318
319pub type StemIndex = std::collections::HashMap<String, Vec<String>>;
322
323pub fn resolve_wikilink(
336 target: &str,
337 source_path: Option<&str>,
338 stem_index: &StemIndex,
339) -> Option<String> {
340 let t = target.trim();
341 if t.is_empty() {
342 return None;
343 }
344 let lower = t.to_lowercase();
345 if lower.ends_with(".md") {
347 return path_exists(t, stem_index).then_some(t.to_string());
348 }
349 if t.contains('/') {
351 let with_ext = format!("{t}.md");
352 return path_exists(&with_ext, stem_index).then_some(with_ext);
353 }
354 let candidates = stem_index.get(&lower)?;
356 if candidates.len() == 1 {
357 return Some(candidates[0].clone());
358 }
359 if let Some(src) = source_path {
360 let src_dir = dir_of(src);
361 let same_dir: Vec<&String> = candidates.iter().filter(|p| dir_of(p) == src_dir).collect();
362 if same_dir.len() == 1 {
363 return Some(same_dir[0].clone());
364 }
365 }
366 None
367}
368
369pub fn rewrite_wikilink_targets(
387 content: &str,
388 old_path: &str,
389 new_path: &str,
390 stem_index: Option<&StemIndex>,
397) -> (String, usize) {
398 if old_path == new_path || old_path.is_empty() {
399 return (content.to_string(), 0);
400 }
401 let old_no_ext = old_path.strip_suffix(".md").unwrap_or(old_path);
402 let new_no_ext = new_path.strip_suffix(".md").unwrap_or(new_path);
403 let old_stem = old_no_ext.rsplit('/').next().unwrap_or(old_no_ext);
404 let new_stem = new_no_ext.rsplit('/').next().unwrap_or(new_no_ext);
405
406 let re = match Regex::new(r"\[\[([^\[\]\n|]+)((?:\|[^\[\]\n]+)*)\]\]") {
407 Ok(r) => r,
408 Err(_) => return (content.to_string(), 0),
409 };
410 let mut count = 0usize;
411 let result = re
412 .replace_all(content, |caps: ®ex::Captures| {
413 let full = caps
414 .get(0)
415 .expect("capture group present after successful match")
416 .as_str();
417 let target = caps
418 .get(1)
419 .expect("capture group present after successful match")
420 .as_str();
421 let alias_part = caps
422 .get(2)
423 .expect("capture group present after successful match")
424 .as_str();
425 let new_target = if target == old_path {
431 Some(new_path)
432 } else if target == old_no_ext {
433 Some(new_no_ext)
434 } else if target == old_stem
435 && old_stem != new_stem
436 && stem_index
437 .and_then(|idx| idx.get(&target.to_lowercase()))
438 .is_some_and(|candidates| candidates.len() == 1)
439 {
440 Some(new_stem)
441 } else {
442 None
443 };
444 match new_target {
445 Some(nt) => {
446 count += 1;
447 format!("[[{nt}{alias_part}]]")
448 }
449 None => full.to_string(),
450 }
451 })
452 .to_string();
453 (result, count)
454}
455
456fn path_exists(path: &str, stem_index: &StemIndex) -> bool {
458 stem_index
459 .get(&stem_of(path))
460 .is_some_and(|bucket| bucket.iter().any(|p| p == path))
461}
462
463fn stem_of(path: &str) -> String {
464 let basename = path.rsplit('/').next().unwrap_or(path);
465 basename
466 .strip_suffix(".md")
467 .or_else(|| basename.strip_suffix(".MD"))
468 .unwrap_or(basename)
469 .to_lowercase()
470}
471
472fn dir_of(path: &str) -> &str {
473 match path.rfind('/') {
474 Some(i) => &path[..i],
475 None => "",
476 }
477}
478
479pub fn extract_headings(content: &str) -> Vec<String> {
483 let re = Regex::new(r"(?m)^(#{1,6})\s+(.+)$").expect("valid regex literal");
484 re.captures_iter(content)
485 .filter_map(|cap| cap.get(2).map(|m| m.as_str().trim().to_string()))
486 .collect()
487}
488
489pub const MIN_SEARCH_SIMILARITY: i32 = 70;
491
492pub fn today_chat_header() -> String {
494 use chrono::Local;
495 let now = Local::now();
496 format!("#### {} {}", now.format("%d %B,"), now.format("%A"))
497}
498
499pub fn today_journal_path() -> String {
501 use chrono::Local;
502 let now = Local::now();
503 format!("journal/{}.{}.md", now.format("%Y.%m"), now.format("%B"))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_norm_newlines() {
512 assert_eq!(norm_new_lines("a\r\nb\r\nc"), "a\nb\nc");
513 assert_eq!(norm_new_lines("a\rb\rc"), "a\nb\nc");
514 }
515
516 #[test]
517 fn test_similar() {
518 assert!(similar("hello", "helo") > 70.0);
519 assert!(similar("test", "test") > 99.0);
520 assert_eq!(similar("", ""), 0.0);
521 }
522
523 #[test]
524 fn test_levenshtein() {
525 assert_eq!(levenshtein("kitten", "sitting"), 3);
526 assert_eq!(levenshtein("test", "test"), 0);
527 }
528
529 #[test]
530 fn test_truncate() {
531 assert_eq!(truncate("hello", 10), "hello");
532 assert_eq!(truncate("hello world", 8), "hello...");
533 }
534
535 #[test]
536 fn test_extract_links() {
537 let md =
538 "See [Rust](brain/Rust.md) and [Go](brain/Go.md) but not [ext](https://example.com)";
539 let links = extract_markdown_links(md);
540 assert_eq!(links.len(), 2);
541 assert_eq!(links[0].0, "Rust");
542 assert_eq!(links[0].1, "brain/Rust.md");
543 }
544
545 fn stem_index(entries: &[&str]) -> StemIndex {
546 let mut idx: StemIndex = StemIndex::new();
547 for path in entries {
548 let stem = path
549 .rsplit('/')
550 .next()
551 .unwrap_or(path)
552 .trim_end_matches(".md")
553 .to_lowercase();
554 idx.entry(stem).or_default().push((*path).to_string());
555 }
556 idx
557 }
558
559 #[test]
560 fn test_extract_wikilinks() {
561 let md = "See [[Rust]] and [[brain/Go|The Go Page]] but not [md](brain/Other.md)";
562 let links = extract_wikilinks(md);
563 assert_eq!(links.len(), 2);
564 assert_eq!(links[0].0, "Rust");
565 assert!(links[0].1.is_none());
566 assert_eq!(links[1].0, "brain/Go");
567 assert_eq!(links[1].1.as_deref(), Some("The Go Page"));
568 }
569
570 #[test]
571 fn test_resolve_wikilink() {
572 let idx = stem_index(&[
573 "brain/Rust.md",
574 "brain/Ownership.md",
575 "lang/Rust.md",
576 "Notes.md",
577 ]);
578 assert_eq!(
580 resolve_wikilink("brain/Rust.md", None, &idx),
581 Some("brain/Rust.md".into())
582 );
583 assert_eq!(resolve_wikilink("brain/Missing.md", None, &idx), None);
584 assert_eq!(
586 resolve_wikilink("brain/Ownership", None, &idx),
587 Some("brain/Ownership.md".into())
588 );
589 assert_eq!(
591 resolve_wikilink("Notes", None, &idx),
592 Some("Notes.md".into())
593 );
594 assert_eq!(
596 resolve_wikilink("Rust", Some("brain/Ownership.md"), &idx),
597 Some("brain/Rust.md".into()),
598 );
599 assert_eq!(
600 resolve_wikilink("Rust", Some("lang/Other.md"), &idx),
601 Some("lang/Rust.md".into())
602 );
603 assert_eq!(resolve_wikilink("Rust", None, &idx), None);
605 assert_eq!(resolve_wikilink("Nowhere", None, &idx), None);
607 assert_eq!(resolve_wikilink("", None, &idx), None);
608 }
609
610 #[test]
611 fn test_rewrite_link_targets() {
612 let md = "See [Rust](brain/Rust.md) and [also](brain/Rust.md); prose brain/Rust.md stays.";
613 let (out, n) = rewrite_link_targets(md, "brain/Rust.md", "brain/Rust Lang.md");
614 assert_eq!(n, 2);
615 assert!(out.contains("[Rust](brain/Rust Lang.md)"));
616 assert!(out.contains("[also](brain/Rust Lang.md)"));
617 assert!(out.contains("prose brain/Rust.md stays"));
619 let (same, zero) = rewrite_link_targets(md, "brain/Rust.md", "brain/Rust.md");
621 assert_eq!(zero, 0);
622 assert_eq!(same, md);
623 }
624
625 #[test]
626 fn test_rewrite_wikilink_targets() {
627 let unique = stem_index(&["brain/Rust.md"]);
629 let md = "Bare [[Rust]] path [[brain/Rust]] full [[brain/Rust.md]] alias [[Rust|Rusty]].";
630 let (out, n) =
631 rewrite_wikilink_targets(md, "brain/Rust.md", "brain/Rust Lang.md", Some(&unique));
632 assert_eq!(n, 4);
633 assert!(out.contains("[[Rust Lang]]"));
634 assert!(out.contains("[[brain/Rust Lang]]"));
635 assert!(out.contains("[[brain/Rust Lang.md]]"));
636 assert!(
637 out.contains("[[Rust Lang|Rusty]]"),
638 "alias preserved: {out}"
639 );
640
641 let ambiguous = stem_index(&["a/Dup.md", "b/Dup.md"]);
644 let md2 = "ambig [[Dup]] explicit [[a/Dup]] full [[a/Dup.md]]";
645 let (out2, n2) = rewrite_wikilink_targets(md2, "a/Dup.md", "a/Moved.md", Some(&ambiguous));
646 assert!(
647 out2.contains("[[Dup]]"),
648 "ambiguous bare link preserved: {out2}"
649 );
650 assert!(
651 out2.contains("[[a/Moved]]"),
652 "explicit path rewritten: {out2}"
653 );
654 assert!(
655 out2.contains("[[a/Moved.md]]"),
656 "full path rewritten: {out2}"
657 );
658 assert_eq!(n2, 2);
659
660 let (out3, n3) =
663 rewrite_wikilink_targets("[[Rust]]", "brain/Rust.md", "lang/Rust.md", Some(&unique));
664 assert_eq!(n3, 0);
665 assert_eq!(out3, "[[Rust]]");
666
667 let (out4, n4) = rewrite_wikilink_targets(
671 "[[Rust]] [r](brain/Rust.md)",
672 "brain/Rust.md",
673 "brain/X.md",
674 None,
675 );
676 assert_eq!(n4, 0); assert!(out4.contains("[[Rust]]"));
678 assert!(out4.contains("[r](brain/Rust.md)"));
679
680 let (same, zero) =
682 rewrite_wikilink_targets(md, "brain/Rust.md", "brain/Rust.md", Some(&unique));
683 assert_eq!(zero, 0);
684 assert_eq!(same, md);
685 }
686
687 #[test]
688 fn test_extract_headings() {
689 let md = "# Title\n## Section\n### Sub\nsome text";
690 let headings = extract_headings(md);
691 assert_eq!(headings, vec!["Title", "Section", "Sub"]);
692 }
693
694 #[test]
695 fn test_ucfirst() {
696 assert_eq!(ucfirst("hello"), "Hello");
697 assert_eq!(ucfirst(""), "");
698 assert_eq!(ucfirst("Already"), "Already");
699 assert_eq!(ucfirst("über"), "Über");
700 }
701
702 #[test]
703 fn test_lcfirst() {
704 assert_eq!(lcfirst("Hello"), "hello");
705 assert_eq!(lcfirst(""), "");
706 assert_eq!(lcfirst("lower"), "lower");
707 }
708
709 #[test]
710 fn test_substr() {
711 assert_eq!(substr("Hello", 0, 3), "Hel");
712 assert_eq!(substr("Hello", 2, 3), "llo");
713 assert_eq!(substr("Hello", 3, 10), "lo");
714 assert_eq!(substr("Hello", 10, 2), "");
715 assert_eq!(substr("", 0, 5), "");
716 assert_eq!(substr("안녕하세요", 0, 2), "안녕");
718 }
719
720 #[test]
721 fn test_is_multiline() {
722 assert!(is_multiline("line one\nline two"));
723 assert!(!is_multiline("single line"));
724 assert!(is_multiline("a\r\nb"));
725 assert!(!is_multiline(""));
726 }
727
728 #[test]
729 fn test_split_text_into_chunks() {
730 let chunks = split_text_into_chunks("Hello", 5);
732 assert_eq!(chunks, vec!["Hello"]);
733
734 let chunks = split_text_into_chunks("This is a test to check the splitting of text", 10);
736 for chunk in &chunks {
737 assert!(
738 chunk.len() <= 10,
739 "chunk too long: '{}' ({})",
740 chunk,
741 chunk.len()
742 );
743 }
744
745 let chunks = split_text_into_chunks("Line one\nLine two\nLine three", 15);
747 assert_eq!(chunks, vec!["Line one", "Line two", "Line three"]);
748
749 let chunks = split_text_into_chunks("Hello world", 0);
751 assert_eq!(chunks, vec!["Hello world"]);
752 }
753
754 #[test]
755 fn test_emoji_prefix() {
756 assert_eq!(emoji_prefix("📝", "WRK Task"), "📝 Task");
757 assert_eq!(emoji_prefix("✅", "Task"), "✅ Task");
758 assert_eq!(emoji_prefix("", "Hello"), "Hello");
759 assert_eq!(emoji_prefix("🎉", "UA Celebration"), "🎉 Celebration");
760 }
761
762 #[test]
763 fn test_has_image() {
764 assert!(has_image("look: "));
765 assert!(!has_image("just text"));
766 }
767}