Skip to main content

strixonomy_owl/
links.rs

1//! Annotation hyperlink extractors (Protégé Wave 2 `*LinkExtractor` ports).
2//!
3//! Keep regex tables in sync with
4//! `extension/webview-ui/src/utils/annotationLinks.ts`.
5
6use regex::Regex;
7use std::sync::OnceLock;
8
9/// A single hyperlink match inside annotation text.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct AnnotationLink {
12    pub kind: String,
13    pub matched_text: String,
14    pub url: String,
15    pub start: usize,
16    pub end: usize,
17}
18
19struct Extractor {
20    name: &'static str,
21    pattern: Regex,
22    /// Replacement template: `$1` is replaced with capture group 1.
23    url_template: &'static str,
24}
25
26fn extractors() -> &'static [Extractor] {
27    static EXTRACTORS: OnceLock<Vec<Extractor>> = OnceLock::new();
28    EXTRACTORS.get_or_init(|| {
29        vec![
30            Extractor {
31                name: "DOI",
32                pattern: Regex::new(r"(?i)DOI:\s*([^\s]+)").expect("DOI pattern"),
33                url_template: "https://doi.org/$1",
34            },
35            Extractor {
36                name: "PubMedId",
37                pattern: Regex::new(r"(?i)PMID:\s*(\d+)").expect("PMID pattern"),
38                url_template: "http://www.ncbi.nlm.nih.gov/pubmed/$1",
39            },
40            Extractor {
41                name: "ORCID",
42                pattern: Regex::new(r"(?i)ORCID:\s*([^\s]+)").expect("ORCID pattern"),
43                url_template: "https://orcid.org/$1",
44            },
45            Extractor {
46                name: "OMIMPS",
47                // Longer OMIMPS before OMIM so "OMIMPS:1" is not partial-matched as OMIM.
48                pattern: Regex::new(r"(?i)OMIMPS:\s*(\d+)").expect("OMIMPS pattern"),
49                url_template: "https://www.omim.org/phenotypicSeries/$1",
50            },
51            Extractor {
52                name: "OMIM",
53                pattern: Regex::new(r"(?i)OMIM:\s*(\d+)").expect("OMIM pattern"),
54                url_template: "https://omim.org/entry/$1",
55            },
56            Extractor {
57                name: "Orphanet",
58                pattern: Regex::new(r"(?i)Orphanet:\s*(\d+)").expect("Orphanet pattern"),
59                url_template: "https://www.orpha.net/consor/cgi-bin/OC_Exp.php?Expert=$1",
60            },
61            Extractor {
62                name: "ISBN-10",
63                pattern: Regex::new(r"(?i)ISBN:(\d{10})").expect("ISBN pattern"),
64                url_template: "http://www.isbnsearch.org/isbn/$1",
65            },
66            Extractor {
67                name: "WikipediaVersioned",
68                pattern: Regex::new(r"(?i)WikipediaVersioned:([^\s]+)").expect("WikiVer pattern"),
69                url_template: "https://wikipedia.org/wiki/index.php?title=$1",
70            },
71            Extractor {
72                name: "Wikipedia",
73                pattern: Regex::new(r"(?i)Wikipedia:([^\s]+)").expect("Wikipedia pattern"),
74                url_template: "https://wikipedia.org/wiki/$1",
75            },
76        ]
77    })
78}
79
80fn apply_template(template: &str, capture: &str) -> String {
81    template.replace("$1", capture)
82}
83
84/// Extract all annotation hyperlinks from `text` (non-overlapping, left-to-right).
85pub fn extract_links(text: &str) -> Vec<AnnotationLink> {
86    let mut found: Vec<AnnotationLink> = Vec::new();
87    for ex in extractors() {
88        for caps in ex.pattern.captures_iter(text) {
89            let Some(m) = caps.get(0) else { continue };
90            let capture = caps.get(1).map(|c| c.as_str()).unwrap_or("");
91            found.push(AnnotationLink {
92                kind: ex.name.to_string(),
93                matched_text: m.as_str().to_string(),
94                url: apply_template(ex.url_template, capture),
95                start: m.start(),
96                end: m.end(),
97            });
98        }
99    }
100    found.sort_by_key(|l| (l.start, l.end));
101    // Drop overlapping matches (prefer earlier / longer already ordered).
102    let mut out = Vec::new();
103    let mut cursor = 0usize;
104    for link in found {
105        if link.start < cursor {
106            continue;
107        }
108        cursor = link.end;
109        out.push(link);
110    }
111    out
112}
113
114/// First matching link URL for `text`, if any (Protégé `extractLinkLiteral` style).
115pub fn extract_first_link_url(text: &str) -> Option<String> {
116    extract_links(text).into_iter().next().map(|l| l.url)
117}
118
119/// Build a regex-based extractor for custom patterns (Protégé `RegExBasedLinkExtractor`).
120pub fn extract_with_pattern(
121    name: &str,
122    pattern: &Regex,
123    url_template: &str,
124    text: &str,
125) -> Option<AnnotationLink> {
126    let caps = pattern.captures(text)?;
127    let m = caps.get(0)?;
128    let capture = caps.get(1).map(|c| c.as_str()).unwrap_or("");
129    Some(AnnotationLink {
130        kind: name.to_string(),
131        matched_text: m.as_str().to_string(),
132        url: apply_template(url_template, capture),
133        start: m.start(),
134        end: m.end(),
135    })
136}
137
138/// Escape markdown specials, then wrap extracted link spans as `[text](url)`.
139pub fn linkify_markdown_text(text: &str) -> String {
140    let links = extract_links(text);
141    if links.is_empty() {
142        return escape_md_plain(text);
143    }
144    let mut out = String::new();
145    let mut idx = 0;
146    for link in &links {
147        if link.start > idx {
148            out.push_str(&escape_md_plain(&text[idx..link.start]));
149        }
150        out.push('[');
151        out.push_str(&escape_md_plain(&link.matched_text));
152        out.push_str("](");
153        out.push_str(&link.url);
154        out.push(')');
155        idx = link.end;
156    }
157    if idx < text.len() {
158        out.push_str(&escape_md_plain(&text[idx..]));
159    }
160    out
161}
162
163fn escape_md_plain(value: &str) -> String {
164    value
165        .replace('\\', "\\\\")
166        .replace('[', "\\[")
167        .replace(']', "\\]")
168        .replace('(', "\\(")
169        .replace(')', "\\)")
170        .replace('<', "\\<")
171        .replace('>', "\\>")
172        .replace('`', "\\`")
173        .replace('*', "\\*")
174        .replace('_', "\\_")
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn doi_and_pmid() {
183        let links = extract_links("See DOI: 10.1000/xyz and PMID: 12345");
184        assert_eq!(links.len(), 2);
185        assert_eq!(links[0].kind, "DOI");
186        assert_eq!(links[0].url, "https://doi.org/10.1000/xyz");
187        assert_eq!(links[1].kind, "PubMedId");
188        assert_eq!(links[1].url, "http://www.ncbi.nlm.nih.gov/pubmed/12345");
189    }
190
191    #[test]
192    fn omimps_before_omim() {
193        let links = extract_links("OMIMPS: 99");
194        assert_eq!(links.len(), 1);
195        assert_eq!(links[0].kind, "OMIMPS");
196    }
197
198    #[test]
199    fn generic_regex_extractor() {
200        let re = Regex::new(r"(?i)CUSTOM:\s*(\w+)").unwrap();
201        let link =
202            extract_with_pattern("Custom", &re, "https://example.org/$1", "CUSTOM: abc").unwrap();
203        assert_eq!(link.url, "https://example.org/abc");
204    }
205
206    #[test]
207    fn linkify_embeds_markdown() {
208        let md = linkify_markdown_text("PMID: 1");
209        assert!(md.contains("[PMID: 1](http://www.ncbi.nlm.nih.gov/pubmed/1)"));
210    }
211}