Skip to main content

web_capture/
xpaste.rs

1//! xpaste.pro URL helpers shared by the CLI and HTTP server.
2
3use url::Url;
4
5pub const INLINE_MARKDOWN_LINE_LIMIT: usize = 1500;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct TextPasteUrl {
9    pub paste_id: String,
10    path_prefix: String,
11}
12
13#[must_use]
14pub fn is_text_paste_url(url: &str) -> bool {
15    parse_text_paste_url(url).is_some()
16}
17
18#[must_use]
19pub fn normalize_url_for_text_content(url: &str) -> String {
20    parse_text_paste_url(url).map_or_else(
21        || url.to_string(),
22        |paste| {
23            format!(
24                "https://xpaste.pro{}/p/{}/raw",
25                paste.path_prefix, paste.paste_id
26            )
27        },
28    )
29}
30
31#[must_use]
32pub fn normalize_url_for_text_page(url: &str) -> String {
33    parse_text_paste_url(url).map_or_else(
34        || url.to_string(),
35        |paste| {
36            format!(
37                "https://xpaste.pro{}/p/{}",
38                paste.path_prefix, paste.paste_id
39            )
40        },
41    )
42}
43
44#[must_use]
45pub fn paste_id(url: &str) -> Option<String> {
46    parse_text_paste_url(url).map(|paste| paste.paste_id)
47}
48
49#[must_use]
50pub fn filename_for_text_url(url: &str) -> String {
51    if let Some(paste) = parse_text_paste_url(url) {
52        return format!("xpaste-pro-{}.txt", paste.paste_id);
53    }
54
55    Url::parse(url).map_or_else(
56        |_| "download.txt".to_string(),
57        |parsed| {
58            let host = parsed.host_str().unwrap_or("download").replace('.', "-");
59            let path = parsed
60                .path()
61                .trim_matches('/')
62                .replace('/', "-")
63                .replace("-raw", "");
64            if path.is_empty() {
65                format!("{host}.txt")
66            } else {
67                format!("{host}-{path}.txt")
68            }
69        },
70    )
71}
72
73#[must_use]
74pub fn append_text_attachment_markdown(markdown: &str, url: &str, raw_text: &str) -> String {
75    let filename = filename_for_text_url(url);
76    let normalized_raw_text = normalize_attachment_text(raw_text);
77    let fence = markdown_fence_for(&normalized_raw_text);
78    let mut result = String::with_capacity(markdown.len() + normalized_raw_text.len() + 128);
79    result.push_str(markdown.trim_end());
80    result.push_str("\n\n## ");
81    result.push_str(&filename);
82    result.push_str("\n\n");
83    result.push_str(&fence);
84    result.push_str("text\n");
85    result.push_str(&normalized_raw_text);
86    if !normalized_raw_text.ends_with('\n') {
87        result.push('\n');
88    }
89    result.push_str(&fence);
90    result.push('\n');
91    result
92}
93
94fn normalize_attachment_text(text: &str) -> String {
95    text.replace("\r\n", "\n").replace('\r', "\n")
96}
97
98fn markdown_fence_for(text: &str) -> String {
99    let mut longest_run = 0;
100    let mut current_run = 0;
101    for ch in text.chars() {
102        if ch == '`' {
103            current_run += 1;
104            longest_run = longest_run.max(current_run);
105        } else {
106            current_run = 0;
107        }
108    }
109    "`".repeat((longest_run + 1).max(3))
110}
111
112fn parse_text_paste_url(url: &str) -> Option<TextPasteUrl> {
113    let parsed = Url::parse(url).ok()?;
114    let host = parsed.host_str()?.to_ascii_lowercase();
115    if host != "xpaste.pro" && host != "www.xpaste.pro" {
116        return None;
117    }
118
119    let parts: Vec<&str> = parsed.path_segments()?.collect();
120    let index = parts.iter().position(|part| *part == "p")?;
121    let mut path_prefix = String::new();
122    if index == 1 && matches!(parts.first(), Some(&("en" | "ru"))) {
123        path_prefix = format!("/{}", parts[0]);
124    } else if index != 0 {
125        return None;
126    }
127
128    let paste_id = parts.get(index + 1)?.to_string();
129    if paste_id.is_empty() {
130        return None;
131    }
132
133    let tail = &parts[index + 2..];
134    if tail.len() > 1 || tail.first().is_some_and(|part| *part != "raw") {
135        return None;
136    }
137
138    Some(TextPasteUrl {
139        paste_id,
140        path_prefix,
141    })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::{
147        append_text_attachment_markdown, filename_for_text_url, is_text_paste_url,
148        normalize_url_for_text_content, normalize_url_for_text_page,
149    };
150
151    #[test]
152    fn normalizes_xpaste_urls_to_raw_text() {
153        assert_eq!(
154            normalize_url_for_text_content("https://xpaste.pro/p/t4q0Lsp0"),
155            "https://xpaste.pro/p/t4q0Lsp0/raw"
156        );
157        assert_eq!(
158            normalize_url_for_text_content("https://xpaste.pro/ru/p/t4q0Lsp0"),
159            "https://xpaste.pro/ru/p/t4q0Lsp0/raw"
160        );
161        assert_eq!(
162            normalize_url_for_text_content("https://xpaste.pro/en/p/t4q0Lsp0/raw"),
163            "https://xpaste.pro/en/p/t4q0Lsp0/raw"
164        );
165    }
166
167    #[test]
168    fn normalizes_xpaste_raw_urls_to_visual_page() {
169        assert_eq!(
170            normalize_url_for_text_page("https://xpaste.pro/p/t4q0Lsp0/raw"),
171            "https://xpaste.pro/p/t4q0Lsp0"
172        );
173        assert_eq!(
174            normalize_url_for_text_page("https://xpaste.pro/ru/p/t4q0Lsp0/raw"),
175            "https://xpaste.pro/ru/p/t4q0Lsp0"
176        );
177        assert_eq!(
178            normalize_url_for_text_page("https://example.com/page"),
179            "https://example.com/page"
180        );
181    }
182
183    #[test]
184    fn detects_only_supported_xpaste_paste_urls() {
185        assert!(is_text_paste_url("https://xpaste.pro/p/t4q0Lsp0"));
186        assert!(is_text_paste_url("https://xpaste.pro/ru/p/t4q0Lsp0"));
187        assert!(is_text_paste_url("https://xpaste.pro/en/p/t4q0Lsp0"));
188        assert!(!is_text_paste_url("https://xpaste.pro/about"));
189        assert!(!is_text_paste_url("https://xpaste.pro/foo/p/t4q0Lsp0"));
190        assert!(!is_text_paste_url(
191            "https://xpaste.pro/p/t4q0Lsp0/raw/extra"
192        ));
193        assert!(!is_text_paste_url("https://example.com/p/t4q0Lsp0"));
194        assert!(!is_text_paste_url("not a url"));
195    }
196
197    #[test]
198    fn derives_text_download_filename() {
199        assert_eq!(
200            filename_for_text_url("https://xpaste.pro/p/t4q0Lsp0"),
201            "xpaste-pro-t4q0Lsp0.txt"
202        );
203    }
204
205    #[test]
206    fn embeds_raw_text_as_named_markdown_attachment() {
207        let markdown = "# Page\n\nVisible content";
208        let raw = "first line\n```inside paste```\nlast line";
209        let result =
210            append_text_attachment_markdown(markdown, "https://xpaste.pro/p/t4q0Lsp0", raw);
211
212        assert!(result.contains("## xpaste-pro-t4q0Lsp0.txt"));
213        assert!(result.contains("````text\nfirst line\n```inside paste```\nlast line\n````"));
214    }
215}