/ src / util.rs
util.rs
  1  use chrono::{DateTime, Utc};
  2  use sauron::prelude::*;
  3  use sauron::vdom::element;
  4  
  5  /// Decode HTML entities in text content
  6  fn decode_entities(text: &str) -> String {
  7      text.replace("&", "&")
  8          .replace("&lt;", "<")
  9          .replace("&gt;", ">")
 10          .replace("&quot;", "\"")
 11          .replace("&#x27;", "'")
 12          .replace("&#x2F;", "/")
 13          .replace("&#x3D;", "=")
 14          .replace("&#39;", "'")
 15          .replace("&nbsp;", " ")
 16  }
 17  
 18  /// Sanitize HTML and convert to Sauron virtual DOM nodes
 19  pub fn parse_html_to_nodes<MSG>(text: &str) -> Vec<Node<MSG>> {
 20      // Configure ammonia to allow code-related tags and links
 21      let sanitized = ammonia::Builder::default()
 22          .add_tags(&["code", "pre", "tt", "a"])  // Add code formatting tags and links
 23          .add_tag_attributes("a", &["href"])     // Allow href attribute on links
 24          .clean(text)
 25          .to_string();
 26      
 27      // Simple HTML parser for common HN tags
 28      // This is a basic implementation - for full HTML parsing you'd want html5ever
 29      parse_simple_html(&sanitized)
 30  }
 31  
 32  fn parse_simple_html<MSG>(html: &str) -> Vec<Node<MSG>> {
 33      let mut nodes = Vec::new();
 34      let mut current_pos = 0;
 35      
 36      while current_pos < html.len() {
 37          if let Some(tag_start) = html[current_pos..].find('<') {
 38              let tag_start = current_pos + tag_start;
 39              
 40              // Add text before tag
 41              if tag_start > current_pos {
 42                  let text_content = &html[current_pos..tag_start];
 43                  if !text_content.trim().is_empty() {
 44                      nodes.push(text(decode_entities(text_content)));
 45                  }
 46              }
 47              
 48              // Find tag end
 49              if let Some(tag_end_pos) = html[tag_start..].find('>') {
 50                  let tag_end = tag_start + tag_end_pos + 1;
 51                  let tag_content = &html[tag_start..tag_end];
 52                  
 53                  if tag_content.starts_with("<p>") || tag_content.starts_with("<p ") {
 54                      // Handle paragraph - find closing tag
 55                      if let Some(close_pos) = html[tag_end..].find("</p>") {
 56                          let p_content = &html[tag_end..tag_end + close_pos];
 57                          nodes.push(element("p", [], parse_simple_html(p_content)));
 58                          current_pos = tag_end + close_pos + 4; // Skip "</p>"
 59                      } else {
 60                          // No closing tag, treat as line break
 61                          nodes.push(element("br", [], []));
 62                          current_pos = tag_end;
 63                      }
 64                  } else if tag_content.starts_with("<i>") {
 65                      if let Some(close_pos) = html[tag_end..].find("</i>") {
 66                          let i_content = &html[tag_end..tag_end + close_pos];
 67                          nodes.push(element("i", [], parse_simple_html(i_content)));
 68                          current_pos = tag_end + close_pos + 4; // Skip "</i>"
 69                      } else {
 70                          current_pos = tag_end;
 71                      }
 72                  } else if tag_content.starts_with("<b>") {
 73                      if let Some(close_pos) = html[tag_end..].find("</b>") {
 74                          let b_content = &html[tag_end..tag_end + close_pos];
 75                          nodes.push(element("b", [], parse_simple_html(b_content)));
 76                          current_pos = tag_end + close_pos + 4; // Skip "</b>"
 77                      } else {
 78                          current_pos = tag_end;
 79                      }
 80                  } else if tag_content.starts_with("<br") {
 81                      nodes.push(element("br", [], []));
 82                      current_pos = tag_end;
 83                  } else if tag_content.starts_with("<code>") {
 84                      if let Some(close_pos) = html[tag_end..].find("</code>") {
 85                          let code_content = &html[tag_end..tag_end + close_pos];
 86                          // Don't parse code content as HTML - treat as literal text
 87                          nodes.push(element("code", [], [text(decode_entities(code_content))]));
 88                          current_pos = tag_end + close_pos + 7; // Skip "</code>"
 89                      } else {
 90                          current_pos = tag_end;
 91                      }
 92                  } else if tag_content.starts_with("<pre>") {
 93                      // For <pre>, we need to find the matching </pre> while ignoring any tags inside
 94                      let mut pre_end = tag_end;
 95                      let mut pre_depth = 1;
 96                      
 97                      while pre_depth > 0 && pre_end < html.len() {
 98                          if let Some(next_tag) = html[pre_end..].find('<') {
 99                              pre_end += next_tag;
100                              if html[pre_end..].starts_with("</pre>") {
101                                  pre_depth -= 1;
102                                  if pre_depth == 0 {
103                                      let pre_content = &html[tag_end..pre_end];
104                                      // Parse the content inside <pre> as HTML to handle nested <code> tags
105                                      nodes.push(element("pre", [], parse_simple_html(pre_content)));
106                                      current_pos = pre_end + 6; // Skip "</pre>"
107                                      break;
108                                  }
109                              } else if html[pre_end..].starts_with("<pre>") {
110                                  pre_depth += 1;
111                              }
112                              pre_end += 1;
113                          } else {
114                              // No more tags found, parse rest as content with potential HTML
115                              let pre_content = &html[tag_end..];
116                              nodes.push(element("pre", [], parse_simple_html(pre_content)));
117                              current_pos = html.len();
118                              break;
119                          }
120                      }
121                      
122                      if pre_depth > 0 {
123                          // Unclosed pre tag, skip it
124                          current_pos = tag_end;
125                      }
126                  } else if tag_content.starts_with("<tt>") {
127                      if let Some(close_pos) = html[tag_end..].find("</tt>") {
128                          let tt_content = &html[tag_end..tag_end + close_pos];
129                          // Don't parse tt content as HTML - treat as literal text
130                          nodes.push(element("tt", [], [text(decode_entities(tt_content))]));
131                          current_pos = tag_end + close_pos + 5; // Skip "</tt>"
132                      } else {
133                          current_pos = tag_end;
134                      }
135                  } else if tag_content.starts_with("<a ") || tag_content.starts_with("<a>") {
136                      if let Some(close_pos) = html[tag_end..].find("</a>") {
137                          let link_content = &html[tag_end..tag_end + close_pos];
138                          
139                          // Extract href attribute if present
140                          let href = if tag_content.contains("href=") {
141                              // Simple href extraction - find href="..." or href='...'
142                              if let Some(href_start) = tag_content.find("href=\"") {
143                                  let href_content_start = href_start + 6; // Skip 'href="'
144                                  if let Some(href_end) = tag_content[href_content_start..].find('"') {
145                                      Some(tag_content[href_content_start..href_content_start + href_end].to_string())
146                                  } else { None }
147                              } else if let Some(href_start) = tag_content.find("href='") {
148                                  let href_content_start = href_start + 6; // Skip "href='"
149                                  if let Some(href_end) = tag_content[href_content_start..].find('\'') {
150                                      Some(tag_content[href_content_start..href_content_start + href_end].to_string())
151                                  } else { None }
152                              } else { None }
153                          } else { None };
154                          
155                          // Create link element with href attribute
156                          if let Some(href_value) = href {
157                              use sauron::prelude::*;
158                              nodes.push(element("a", [
159                                  attr("href", href_value),
160                                  attr("target", "_blank"),
161                                  attr("rel", "noopener noreferrer")
162                              ], parse_simple_html(link_content)));
163                          } else {
164                              // No href, just render as span
165                              nodes.push(element("span", [], parse_simple_html(link_content)));
166                          }
167                          current_pos = tag_end + close_pos + 4; // Skip "</a>"
168                      } else {
169                          current_pos = tag_end;
170                      }
171                  } else {
172                      // Skip unknown tags
173                      current_pos = tag_end;
174                  }
175              } else {
176                  // Malformed tag, treat as text
177                  nodes.push(text(decode_entities(&html[current_pos..])));
178                  break;
179              }
180          } else {
181              // No more tags, add remaining text
182              let remaining = &html[current_pos..];
183              if !remaining.trim().is_empty() {
184                  nodes.push(text(decode_entities(remaining)));
185              }
186              break;
187          }
188      }
189      
190      nodes
191  }
192  
193  /// Return the time ago for a date
194  pub fn time_ago(date: DateTime<Utc>) -> String {
195      let now = Utc::now();
196  
197      const SECONDS_IN_MINUTE: f32 = 60.0;
198      const SECONDS_IN_HOUR: f32 = SECONDS_IN_MINUTE * 60.0;
199      const SECONDS_IN_DAY: f32 = SECONDS_IN_HOUR * 24.0;
200      const SECONDS_IN_YEAR: f32 = SECONDS_IN_DAY * 365.0; // Ignore leap years for now
201  
202      let seconds = (now - date).num_seconds() as f32;
203      if seconds < SECONDS_IN_MINUTE {
204          let seconds = seconds.floor() as i32;
205          if seconds < 2 {
206              format!("{} second", seconds)
207          } else {
208              format!("{} seconds", seconds)
209          }
210      } else if seconds < SECONDS_IN_HOUR {
211          let minutes = (seconds / SECONDS_IN_MINUTE).floor() as i32;
212          if minutes < 2 {
213              format!("{} minute", minutes)
214          } else {
215              format!("{} minutes", minutes)
216          }
217      } else if seconds < SECONDS_IN_DAY {
218          let hours = (seconds / SECONDS_IN_HOUR).floor() as i32;
219          if hours < 2 {
220              format!("{} hour", hours)
221          } else {
222              format!("{} hours", hours)
223          }
224      } else if seconds < SECONDS_IN_YEAR {
225          let days = (seconds / SECONDS_IN_DAY).floor() as i32;
226          if days < 2 {
227              format!("{} day", days)
228          } else {
229              format!("{} days", days)
230          }
231      } else {
232          let years = (seconds / SECONDS_IN_YEAR).floor() as i32;
233          if years < 2 {
234              format!("{} year", years)
235          } else {
236              format!("{} years", years)
237          }
238      }
239  }