Skip to main content

piki_gui/
link_handler.rs

1#![allow(dead_code)]
2
3use pulldown_cmark::{Event, Parser, Tag, TagEnd};
4use regex::Regex;
5
6#[derive(Debug, Clone)]
7pub struct Link {
8    pub start: usize,
9    pub end: usize,
10    pub destination: String,
11    pub text: String,
12}
13
14/// Parse Markdown content and extract links with their positions
15pub fn extract_links(content: &str) -> Vec<Link> {
16    let mut links = Vec::new();
17    let parser = Parser::new(content);
18
19    let mut current_link: Option<(usize, String)> = None;
20    let mut link_text = String::new();
21
22    for (event, range) in parser.into_offset_iter() {
23        match event {
24            Event::Start(Tag::Link { dest_url, .. }) => {
25                current_link = Some((range.start, dest_url.to_string()));
26                link_text.clear();
27            }
28            Event::Text(text) if current_link.is_some() => {
29                link_text.push_str(&text);
30            }
31            Event::End(TagEnd::Link) => {
32                if let Some((start, dest)) = current_link.take() {
33                    links.push(Link {
34                        start,
35                        end: range.end,
36                        destination: dest,
37                        text: link_text.clone(),
38                    });
39                }
40            }
41            _ => {}
42        }
43    }
44
45    // Also support wiki-style [[note]] links
46    let wiki_link_re = Regex::new(r"\[\[([^\]]+)\]\]").unwrap();
47    for cap in wiki_link_re.captures_iter(content) {
48        if let Some(matched) = cap.get(0) {
49            let note = cap.get(1).unwrap().as_str().to_string();
50            links.push(Link {
51                start: matched.start(),
52                end: matched.end(),
53                destination: note.clone(),
54                text: note,
55            });
56        }
57    }
58
59    links.sort_by_key(|l| l.start);
60    links
61}
62
63/// Find link at a specific character position in the text
64pub fn find_link_at_position(links: &[Link], pos: usize) -> Option<&Link> {
65    links
66        .iter()
67        .find(|link| pos >= link.start && pos < link.end)
68}
69
70/// Returns true if the destination is an external link that should be opened
71/// in the system browser/handler rather than loaded as a wiki note.
72///
73/// Recognises URLs with an explicit authority (e.g. `http://`, `https://`,
74/// `ftp://`, `file://`) as well as authority-less schemes like `mailto:` and
75/// `tel:`. Plain note names (including ones that happen to contain a colon,
76/// such as `Notes: Meeting`) are treated as internal.
77pub fn is_external_link(destination: &str) -> bool {
78    let dest = destination.trim_start();
79
80    // URLs with an authority component: <scheme>://...
81    if let Some(scheme_end) = dest.find("://") {
82        let scheme = &dest[..scheme_end];
83        if !scheme.is_empty()
84            && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
85            && scheme
86                .chars()
87                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
88        {
89            return true;
90        }
91    }
92
93    // Authority-less schemes that should still be handed off to the system.
94    let lower = dest.to_ascii_lowercase();
95    lower.starts_with("mailto:") || lower.starts_with("tel:")
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_markdown_links() {
104        let content = "This is a [test link](target.md) in markdown.";
105        let links = extract_links(content);
106        assert_eq!(links.len(), 1);
107        assert_eq!(links[0].destination, "target.md");
108        assert_eq!(links[0].text, "test link");
109    }
110
111    #[test]
112    fn test_wiki_links() {
113        let content = "This is a [[WikiPage]] link.";
114        let links = extract_links(content);
115        assert_eq!(links.len(), 1);
116        assert_eq!(links[0].destination, "WikiPage");
117    }
118
119    #[test]
120    fn test_mixed_links() {
121        let content = "A [markdown](note1) and [[wiki]] link.";
122        let links = extract_links(content);
123        assert_eq!(links.len(), 2);
124    }
125
126    #[test]
127    fn test_is_external_link() {
128        // External: schemes with an authority component
129        assert!(is_external_link("http://example.com"));
130        assert!(is_external_link("https://example.com/path?q=1"));
131        assert!(is_external_link("ftp://files.example.com"));
132        assert!(is_external_link("file:///etc/hosts"));
133        assert!(is_external_link("HTTPS://EXAMPLE.COM"));
134        assert!(is_external_link("  https://example.com"));
135
136        // External: authority-less schemes
137        assert!(is_external_link("mailto:user@example.com"));
138        assert!(is_external_link("tel:+1234567890"));
139
140        // Internal: plain note names, including ones containing a colon
141        assert!(!is_external_link("frontpage"));
142        assert!(!is_external_link("some/note.md"));
143        assert!(!is_external_link("[[WikiPage]]"));
144        assert!(!is_external_link("Notes: Meeting"));
145        assert!(!is_external_link("C:\\path\\file"));
146    }
147}