Skip to main content

archivist_core/
util.rs

1//! Utility helpers: supported-site URL detection, embed formatting.
2
3/// Fanfiction sites FicHub supports (a subset of the 107-site scraper parity;
4/// the full list lives in the `fanfic-scrapers` crate).
5const SUPPORTED_HOSTS: &[&str] = &[
6    "archiveofourown.org",
7    "www.archiveofourown.org",
8    "fanfiction.net",
9    "www.fanfiction.net",
10    "royalroad.com",
11    "www.royalroad.com",
12    "fictionpress.com",
13    "www.fictionpress.com",
14    "spacebattles.com",
15    "forums.spacebattles.com",
16    "sufficientvelocity.com",
17    "forums.sufficientvelocity.com",
18    "questionablequesting.com",
19    "forums.questionablequesting.com",
20    "wattpad.com",
21    "www.wattpad.com",
22    "quotev.com",
23    "www.quotev.com",
24    "hp-fanficarchive.com",
25    "www.hp-fanficarchive.com",
26    "fimfiction.net",
27    "www.fimfiction.net",
28    "tthfanfic.org",
29    "www.tthfanfic.org",
30];
31
32/// Extract the first supported fanfiction URL from a string (message content).
33/// Returns the URL as-is (normalized by the caller if needed).
34pub fn extract_fanfic_url(text: &str) -> Option<String> {
35    // Simple heuristic: scan words for `http(s)://` and check host.
36    for word in text.split_whitespace() {
37        let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '/' && c != ':'
38            && c != '.' && c != '-' && c != '_' && c != '?' && c != '=' && c != '&');
39        let lower = cleaned.to_ascii_lowercase();
40        if lower.starts_with("http://") || lower.starts_with("https://") {
41            if let Ok(parsed) = url::Url::parse(&cleaned) {
42                if let Some(host) = parsed.host_str() {
43                    if SUPPORTED_HOSTS.iter().any(|h| host == *h || host.ends_with(h)) {
44                        return Some(cleaned.to_string());
45                    }
46                }
47            }
48        }
49    }
50    None
51}
52
53/// Normalize a URL to a form FicHub accepts (add scheme if missing).
54pub fn normalize_url(raw: &str) -> String {
55    let raw = raw.trim();
56    if raw.starts_with("http://") || raw.starts_with("https://") {
57        raw.to_string()
58    } else {
59        format!("https://{raw}")
60    }
61}
62
63/// Format a word count compactly: 1234 → "1.2k", 1500000 → "1.5M".
64pub fn format_words(words: i64) -> String {
65    if words >= 1_000_000 {
66        format!("{:.1}M", words as f64 / 1_000_000.0)
67    } else if words >= 1_000 {
68        format!("{:.1}k", words as f64 / 1_000.0)
69    } else {
70        words.to_string()
71    }
72}
73
74/// Truncate a string to `max` chars, appending `…` when cut.
75pub fn truncate(s: &str, max: usize) -> String {
76    if s.chars().count() <= max {
77        s.to_string()
78    } else {
79        let cut: String = s.chars().take(max).collect();
80        format!("{cut}…")
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn extracts_ao3_url() {
90        let text = "check out https://archiveofourown.org/works/12345/chapters/1 !";
91        assert_eq!(
92            extract_fanfic_url(text).as_deref(),
93            Some("https://archiveofourown.org/works/12345/chapters/1")
94        );
95    }
96
97    #[test]
98    fn extracts_ffn_url() {
99        let text = "Read https://www.fanfiction.net/s/12345678/1/ it's good";
100        assert_eq!(
101            extract_fanfic_url(text).as_deref(),
102            Some("https://www.fanfiction.net/s/12345678/1/")
103        );
104    }
105
106    #[test]
107    fn extracts_royalroad() {
108        let text = "https://www.royalroad.com/fiction/12345/test";
109        assert_eq!(
110            extract_fanfic_url(text).as_deref(),
111            Some("https://www.royalroad.com/fiction/12345/test")
112        );
113    }
114
115    #[test]
116    fn no_url_no_match() {
117        assert_eq!(extract_fanfic_url("just some text no links"), None);
118        assert_eq!(extract_fanfic_url("https://example.com/not-fic"), None);
119    }
120
121    #[test]
122    fn normalizes_bare_domain() {
123        assert_eq!(normalize_url("archiveofourown.org/works/1"), "https://archiveofourown.org/works/1");
124        assert_eq!(normalize_url("https://a.com/x"), "https://a.com/x");
125    }
126
127    #[test]
128    fn formats_words() {
129        assert_eq!(format_words(999), "999");
130        assert_eq!(format_words(1_200), "1.2k");
131        assert_eq!(format_words(1_500_000), "1.5M");
132    }
133
134    #[test]
135    fn truncates() {
136        assert_eq!(truncate("hello", 10), "hello");
137        assert_eq!(truncate("hello world", 5), "hello…");
138    }
139}