1use std::sync::mpsc::{channel, Receiver, TryRecvError};
12
13#[derive(Debug, Clone, Default)]
15pub struct ReaderDoc {
16 pub title: String,
17 pub text: String,
18 pub links: Vec<(String, String)>,
20}
21
22enum State {
23 Idle,
24 Loading(String),
25 Loaded(Box<ReaderDoc>),
26 Error(String, String),
27}
28
29pub struct WebReader {
31 state: State,
32 url: Option<String>,
33 history: Vec<String>,
34 pending: Option<(String, Receiver<Result<ReaderDoc, String>>)>,
35}
36
37impl Default for WebReader {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl WebReader {
44 pub fn new() -> Self {
45 Self {
46 state: State::Idle,
47 url: None,
48 history: Vec::new(),
49 pending: None,
50 }
51 }
52
53 pub fn is_open(&self) -> bool {
54 !matches!(self.state, State::Idle)
55 }
56 pub fn can_go_back(&self) -> bool {
57 self.history.len() > 1
58 }
59
60 pub fn close(&mut self) {
61 self.state = State::Idle;
62 self.url = None;
63 self.history.clear();
64 self.pending = None;
65 }
66
67 pub fn open(&mut self, url: &str) {
69 self.history.push(url.to_string());
70 self.load(url.to_string());
71 }
72
73 pub fn back(&mut self) {
75 if self.history.len() > 1 {
76 self.history.pop();
77 if let Some(prev) = self.history.last().cloned() {
78 self.load(prev);
79 }
80 }
81 }
82
83 fn load(&mut self, url: String) {
84 let (tx, rx) = channel();
85 let fetch_url = url.clone();
86 std::thread::spawn(move || {
87 let _ = tx.send(fetch_reader(&fetch_url));
88 });
89 self.state = State::Loading(url.clone());
90 self.url = Some(url.clone());
91 self.pending = Some((url, rx));
92 }
93
94 pub fn poll(&mut self) {
96 let Some((url, rx)) = &self.pending else {
97 return;
98 };
99 match rx.try_recv() {
100 Ok(Ok(doc)) => {
101 self.state = State::Loaded(Box::new(doc));
102 self.pending = None;
103 }
104 Ok(Err(e)) => {
105 self.state = State::Error(url.clone(), e);
106 self.pending = None;
107 }
108 Err(TryRecvError::Empty) => {}
109 Err(TryRecvError::Disconnected) => {
110 let u = url.clone();
111 self.state = State::Error(u, "fetch stopped".into());
112 self.pending = None;
113 }
114 }
115 }
116
117 pub fn view(&self) -> Option<WebView> {
119 match &self.state {
120 State::Idle => None,
121 State::Loading(url) => Some(WebView {
122 url: url.clone(),
123 title: String::new(),
124 text: String::new(),
125 links: Vec::new(),
126 loading: true,
127 error: None,
128 }),
129 State::Loaded(d) => Some(WebView {
130 url: self.url.clone().unwrap_or_default(),
131 title: if d.title.is_empty() {
132 self.url.clone().unwrap_or_default()
133 } else {
134 d.title.clone()
135 },
136 text: d.text.clone(),
137 links: d.links.clone(),
138 loading: false,
139 error: None,
140 }),
141 State::Error(url, msg) => Some(WebView {
142 url: url.clone(),
143 title: url.clone(),
144 text: String::new(),
145 links: Vec::new(),
146 loading: false,
147 error: Some(msg.clone()),
148 }),
149 }
150 }
151}
152
153#[derive(Clone)]
155pub struct WebView {
156 pub url: String,
157 pub title: String,
158 pub text: String,
159 pub links: Vec<(String, String)>,
160 pub loading: bool,
161 pub error: Option<String>,
162}
163
164fn fetch_reader(url: &str) -> Result<ReaderDoc, String> {
166 let rt = tokio::runtime::Builder::new_current_thread()
167 .enable_all()
168 .build()
169 .map_err(|e| e.to_string())?;
170 rt.block_on(async {
171 let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
172 let status = resp.status();
173 let final_url = resp.url().to_string();
174 let body = resp.text().await.map_err(|e| e.to_string())?;
175 if !status.is_success() {
176 return Err(format!("HTTP {}", status.as_u16()));
177 }
178 Ok(read_html(&body, &final_url))
179 })
180}
181
182pub fn read_html(html: &str, base_url: &str) -> ReaderDoc {
188 let cleaned = remove_blocks(html);
189 let title = extract_tag_text(&cleaned, "title").unwrap_or_default();
190 let links = extract_links(&cleaned, base_url);
191 let text = to_text(&cleaned);
192 ReaderDoc {
193 title: decode_entities(&title).trim().to_string(),
194 text,
195 links,
196 }
197}
198
199fn remove_blocks(html: &str) -> String {
201 let mut out = String::with_capacity(html.len());
202 let lower = html.to_ascii_lowercase();
203 let bytes = html.as_bytes();
204 let mut i = 0;
205 while i < bytes.len() {
206 let rest = &lower[i..];
207 if let Some(end) = block_end(rest, "<script", "</script>")
208 .or_else(|| block_end(rest, "<style", "</style>"))
209 .or_else(|| comment_end(rest))
210 {
211 i += end;
212 } else {
213 out.push(html[i..].chars().next().unwrap_or(' '));
214 i += html[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
215 }
216 }
217 out
218}
219
220fn block_end(rest: &str, open: &str, close: &str) -> Option<usize> {
221 if rest.starts_with(open) {
222 return Some(
223 rest.find(close)
224 .map(|e| e + close.len())
225 .unwrap_or(rest.len()),
226 );
227 }
228 None
229}
230
231fn comment_end(rest: &str) -> Option<usize> {
232 if rest.starts_with("<!--") {
233 return Some(rest.find("-->").map(|e| e + 3).unwrap_or(rest.len()));
234 }
235 None
236}
237
238fn extract_tag_text(html: &str, tag: &str) -> Option<String> {
240 let lower = html.to_ascii_lowercase();
241 let open = format!("<{tag}");
242 let start = lower.find(&open)?;
243 let gt = lower[start..].find('>')? + start + 1;
244 let end = lower[gt..].find(&format!("</{tag}"))? + gt;
245 Some(html[gt..end].to_string())
246}
247
248fn extract_links(html: &str, base: &str) -> Vec<(String, String)> {
250 let base_url = reqwest::Url::parse(base).ok();
251 let lower = html.to_ascii_lowercase();
252 let mut links = Vec::new();
253 let mut search = 0;
254 while let Some(rel) = lower[search..].find("<a ") {
255 let at = search + rel;
256 let Some(gt) = lower[at..].find('>') else {
257 break;
258 };
259 let tag_end = at + gt;
260 let tag = &html[at..tag_end];
261 let href = attr_value(tag, "href");
262 let inner_end = lower[tag_end..]
264 .find("</a")
265 .map(|e| tag_end + e)
266 .unwrap_or(tag_end);
267 let text = decode_entities(&to_text(&html[tag_end + 1..inner_end]))
268 .trim()
269 .to_string();
270 search = inner_end + 3;
271 if let Some(href) = href {
272 if href.starts_with('#')
273 || href.starts_with("javascript:")
274 || href.starts_with("mailto:")
275 {
276 continue;
277 }
278 let abs = base_url
279 .as_ref()
280 .and_then(|b| b.join(&href).ok())
281 .map(|u| u.to_string())
282 .unwrap_or(href);
283 if abs.starts_with("http") && !text.is_empty() {
284 links.push((text, abs));
285 }
286 }
287 }
288 links.truncate(80);
289 links
290}
291
292fn attr_value(tag: &str, name: &str) -> Option<String> {
294 let lower = tag.to_ascii_lowercase();
295 let key = format!("{name}=");
296 let idx = lower.find(&key)? + key.len();
297 let rest = &tag[idx..];
298 let quote = rest.chars().next()?;
299 if quote == '"' || quote == '\'' {
300 let end = rest[1..].find(quote)? + 1;
301 Some(rest[1..end].to_string())
302 } else {
303 let end = rest
304 .find(|c: char| c.is_whitespace() || c == '>')
305 .unwrap_or(rest.len());
306 Some(rest[..end].to_string())
307 }
308}
309
310fn to_text(html: &str) -> String {
313 let mut out = String::with_capacity(html.len());
314 let bytes = html.as_bytes();
315 let mut i = 0;
316 while i < bytes.len() {
317 if bytes[i] == b'<' {
318 let end = html[i..]
319 .find('>')
320 .map(|e| i + e + 1)
321 .unwrap_or(bytes.len());
322 let tag = html[i..end].to_ascii_lowercase();
323 if is_break_tag(&tag) {
324 out.push('\n');
325 } else {
326 out.push(' ');
327 }
328 i = end;
329 } else {
330 out.push(html[i..].chars().next().unwrap_or(' '));
331 i += html[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
332 }
333 }
334 collapse_ws(&decode_entities(&out))
335}
336
337fn is_break_tag(tag: &str) -> bool {
338 for t in [
339 "</p",
340 "<br",
341 "</div",
342 "</h1",
343 "</h2",
344 "</h3",
345 "</h4",
346 "</li",
347 "</tr",
348 "</section",
349 "</article",
350 "</header",
351 "</footer",
352 ] {
353 if tag.starts_with(t) {
354 return true;
355 }
356 }
357 false
358}
359
360fn collapse_ws(s: &str) -> String {
362 let mut out = String::with_capacity(s.len());
363 let mut blank_run = 0;
364 for line in s.lines() {
365 let mut trimmed = String::with_capacity(line.len());
366 let mut prev_space = false;
367 for c in line.chars() {
368 if c.is_whitespace() {
369 if !prev_space {
370 trimmed.push(' ');
371 }
372 prev_space = true;
373 } else {
374 trimmed.push(c);
375 prev_space = false;
376 }
377 }
378 let t = trimmed.trim();
379 if t.is_empty() {
380 blank_run += 1;
381 if blank_run <= 1 && !out.is_empty() {
382 out.push('\n');
383 }
384 } else {
385 blank_run = 0;
386 out.push_str(t);
387 out.push('\n');
388 }
389 }
390 out.trim().to_string()
391}
392
393fn decode_entities(s: &str) -> String {
395 let mut out = String::with_capacity(s.len());
396 let mut chars = s.char_indices().peekable();
397 while let Some((i, c)) = chars.next() {
398 if c != '&' {
399 out.push(c);
400 continue;
401 }
402 let rest = &s[i..];
403 let Some(semi) = rest[..rest.len().min(12)].find(';') else {
404 out.push('&');
405 continue;
406 };
407 let ent = &rest[1..semi];
408 let decoded = match ent {
409 "amp" => Some('&'),
410 "lt" => Some('<'),
411 "gt" => Some('>'),
412 "quot" => Some('"'),
413 "apos" | "#39" => Some('\''),
414 "nbsp" => Some(' '),
415 "mdash" | "#8212" => Some('—'),
416 "ndash" | "#8211" => Some('–'),
417 "hellip" | "#8230" => Some('…'),
418 "rsquo" | "#8217" => Some('\u{2019}'),
419 "lsquo" | "#8216" => Some('\u{2018}'),
420 "ldquo" | "#8220" => Some('\u{201C}'),
421 "rdquo" | "#8221" => Some('\u{201D}'),
422 _ => ent
423 .strip_prefix('#')
424 .and_then(|n| n.parse::<u32>().ok())
425 .and_then(char::from_u32),
426 };
427 if let Some(d) = decoded {
428 out.push(d);
429 for _ in 0..(semi) {
430 chars.next();
431 }
432 } else {
433 out.push('&');
434 }
435 }
436 out
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn reads_title_text_and_absolute_links() {
445 let html = r##"
446 <html><head><title>Pixiel & Co</title>
447 <style>body{color:red}</style></head>
448 <body>
449 <script>var x = '<not text>';</script>
450 <h1>The Forge</h1>
451 <p>Where prompts are hammered into worlds.</p>
452 <a href="/about">About us</a>
453 <a href="https://pixygon.io/thread">The Thread</a>
454 <a href="#top">skip</a>
455 </body></html>
456 "##;
457 let doc = read_html(html, "https://pixiel.ai/forge");
458 assert_eq!(doc.title, "Pixiel & Co");
459 assert!(doc.text.contains("The Forge"));
460 assert!(doc.text.contains("hammered into worlds"));
461 assert!(!doc.text.contains("var x"), "script contents stripped");
462 assert!(!doc.text.contains("color:red"), "style contents stripped");
463 assert!(doc
465 .links
466 .iter()
467 .any(|(t, u)| t == "About us" && u == "https://pixiel.ai/about"));
468 assert!(doc
469 .links
470 .iter()
471 .any(|(_, u)| u == "https://pixygon.io/thread"));
472 assert!(!doc.links.iter().any(|(t, _)| t == "skip"));
473 }
474
475 #[test]
476 fn decodes_entities() {
477 assert_eq!(
478 decode_entities("a & b <c> 'd' — e"),
479 "a & b <c> 'd' — e"
480 );
481 }
482}