1use regex::Regex;
7use std::sync::OnceLock;
8
9#[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 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 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
84pub 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 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
114pub fn extract_first_link_url(text: &str) -> Option<String> {
116 extract_links(text).into_iter().next().map(|l| l.url)
117}
118
119pub 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
138pub 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}