1type Attrs = Vec<(String, Option<String>)>;
13type Element = (String, Attrs, String);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Row {
18 pub select_value: Option<String>,
20 pub cells: Vec<String>,
22 pub actions: RowActions,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RowActions {
29 pub view: Option<String>,
31 pub edit: Option<String>,
33 pub delete_action: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct FilterOption {
41 pub value: String,
43 pub label: String,
45 pub selected: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct EmptyTable {
52 pub reason: String,
55 pub clear: Option<String>,
57 pub first_page: Option<String>,
59}
60
61pub fn empty_table(html: &str) -> Option<EmptyTable> {
63 let (_, attrs, inner) = tags_where(html, |_, attrs| attr_value(attrs, "data-empty").is_some())
64 .into_iter()
65 .next()?;
66 let link = |kind: &str| {
67 open_tags(&inner)
68 .into_iter()
69 .find(|(_, attrs, _)| attr_value(attrs, "data-empty-link").as_deref() == Some(kind))
70 .and_then(|(_, attrs, _)| attr_value(&attrs, "href"))
71 };
72 Some(EmptyTable {
73 reason: attr_value(&attrs, "data-empty").unwrap_or_default(),
74 clear: link("clear"),
75 first_page: link("first-page"),
76 })
77}
78
79pub fn rows(html: &str) -> Vec<Row> {
81 let mut out = Vec::new();
82 for (_, attrs, inner) in elements(html, "tr") {
83 let Some(id) = attr_value(&attrs, "id") else {
84 continue;
85 };
86 if !id.starts_with("row-") {
87 continue;
88 }
89 out.push(row(&inner));
90 }
91 out
92}
93
94pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
97 rows(html).into_iter().find_map(|row| {
98 let matches = row.select_value.as_deref() == Some(key)
99 || row
100 .actions
101 .view
102 .as_deref()
103 .is_some_and(|href| href_contains_key(href, key))
104 || row
105 .actions
106 .edit
107 .as_deref()
108 .is_some_and(|href| href_contains_key(href, key))
109 || row
110 .actions
111 .delete_action
112 .as_deref()
113 .is_some_and(|action| href_contains_key(action, key));
114 matches.then_some(row.actions)
115 })
116}
117
118pub fn field_error(html: &str, name: &str) -> Option<String> {
121 let target = format!("{name}-error");
122 let (_, _, inner) = tags_where(html, |_, attrs| {
123 attr_value(attrs, "id").as_deref() == Some(target.as_str())
124 })
125 .into_iter()
126 .next()?;
127 let text = strip_text(&inner);
128 (!text.is_empty()).then_some(text)
129}
130
131pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
134 let (tag, _, inner) = tags_where(html, |_, attrs| {
135 attr_value(attrs, "data-filter-name").as_deref() == Some(name)
136 })
137 .into_iter()
138 .next()?;
139 if tag != "select" {
140 return None;
141 }
142 let options = elements(&inner, "option")
143 .into_iter()
144 .map(|(_, attrs, inner)| FilterOption {
145 value: attr_value(&attrs, "value").unwrap_or_default(),
146 label: strip_text(&inner),
147 selected: is_selected(&attrs),
148 })
149 .collect();
150 Some(options)
151}
152
153fn row(row_html: &str) -> Row {
154 let mut select_value = None;
155 for (tag, attrs, _) in open_tags(row_html) {
156 if tag == "input" && attr_value(&attrs, "aria-label").as_deref() == Some("Select row") {
157 select_value = attr_value(&attrs, "value");
158 break;
159 }
160 }
161 let mut cells = Vec::new();
162 for (_, _, inner) in elements(row_html, "td") {
163 cells.push(strip_text(&inner));
164 }
165 let mut view_href = None;
166 let mut edit_href = None;
167 let mut delete_action = None;
168 for (tag, attrs, _) in open_tags(row_html) {
169 match (tag.as_str(), attr_value(&attrs, "aria-label").as_deref()) {
170 ("a", Some("View")) => view_href = attr_value(&attrs, "href"),
171 ("a", Some("Edit")) => edit_href = attr_value(&attrs, "href"),
172 ("button", Some("Delete")) => delete_action = attr_value(&attrs, "formaction"),
173 _ => {}
174 }
175 }
176 Row {
177 select_value,
178 cells,
179 actions: RowActions {
180 view: view_href,
181 edit: edit_href,
182 delete_action,
183 },
184 }
185}
186
187fn href_contains_key(href: &str, key: &str) -> bool {
188 href.split(['?', '#', '/', '&'])
189 .any(|segment| segment == key)
190}
191
192fn is_selected(attrs: &Attrs) -> bool {
193 attrs.iter().any(|(name, _)| name == "selected")
194}
195
196fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
197 attrs
198 .iter()
199 .find(|(attr, _)| attr == name)
200 .and_then(|(_, value)| value.clone())
201}
202
203fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
208 open_tags(html)
209 .into_iter()
210 .filter(|(tag, attrs, _)| keep(tag, attrs))
211 .map(|(tag, attrs, open_end)| {
212 let inner = inner_html(html, &tag, open_end);
213 (tag, attrs, inner)
214 })
215 .collect()
216}
217
218fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
219 if is_void(tag) {
220 return String::new();
221 }
222 let after = &html[open_end + 1..];
225 let open = format!("<{tag}");
226 let close = format!("</{tag}>");
227 let mut depth = 1usize;
228 let mut pos = 0;
229 while let Some(at) = after[pos..].find('<') {
230 let at = pos + at;
231 let rest = &after[at..];
232 if rest.starts_with(close.as_str()) {
233 depth -= 1;
234 if depth == 0 {
235 return after[..at].to_string();
236 }
237 pos = at + close.len();
238 } else if rest.starts_with(open.as_str())
239 && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
240 {
241 depth += 1;
242 pos = at + open.len();
243 } else {
244 pos = at + 1;
245 }
246 }
247 String::new()
248}
249
250fn elements(html: &str, tag: &str) -> Vec<Element> {
251 tags_where(html, |name, _| name == tag)
252}
253
254fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
255 let mut out = Vec::new();
256 let mut pos = 0;
257 while let Some(start) = html[pos..].find('<') {
258 let abs_start = pos + start;
259 let Some(end) = tag_end(html, abs_start) else {
260 break;
261 };
262 let raw = &html[abs_start + 1..end];
263 pos = end + 1;
264 if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
265 continue;
266 }
267 let (tag, attrs) = parse_tag(raw);
268 if tag.is_empty() {
269 continue;
270 }
271 out.push((tag, attrs, end));
272 }
273 out
274}
275
276fn parse_tag(raw: &str) -> (String, Attrs) {
277 let mut attrs = Vec::new();
278 let bytes = raw.as_bytes();
279 let mut pos = 0;
280 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
281 pos += 1;
282 }
283 let tag = raw[..pos].to_ascii_lowercase();
284 while pos < bytes.len() {
285 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
286 pos += 1;
287 }
288 if pos >= bytes.len() || bytes[pos] == b'/' {
289 break;
290 }
291 let name_start = pos;
292 while pos < bytes.len()
293 && (bytes[pos].is_ascii_alphanumeric()
294 || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
295 {
296 pos += 1;
297 }
298 if name_start == pos {
299 pos += 1;
300 while pos < bytes.len() && !raw.is_char_boundary(pos) {
301 pos += 1;
302 }
303 continue;
304 }
305 let name = raw[name_start..pos].to_ascii_lowercase();
306 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
307 pos += 1;
308 }
309 if pos >= bytes.len() || bytes[pos] != b'=' {
310 attrs.push((name, None));
311 continue;
312 }
313 pos += 1;
314 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
315 pos += 1;
316 }
317 if pos >= bytes.len() {
318 attrs.push((name, Some(String::new())));
319 break;
320 }
321 let quote = bytes[pos];
322 if quote == b'"' || quote == b'\'' {
323 pos += 1;
324 let value_start = pos;
325 while pos < bytes.len() && bytes[pos] != quote {
326 pos += 1;
327 }
328 attrs.push((
329 name,
330 Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
331 ));
332 pos = (pos + 1).min(bytes.len());
333 } else {
334 let value_start = pos;
335 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
336 pos += 1;
337 }
338 attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
339 }
340 }
341 (tag, attrs)
342}
343
344fn tag_end(html: &str, start: usize) -> Option<usize> {
345 let bytes = html.as_bytes();
346 let mut quote = None;
347 let mut pos = start + 1;
348 while pos < bytes.len() {
349 let byte = bytes[pos];
350 if let Some(q) = quote {
351 if byte == q {
352 quote = None;
353 }
354 } else if byte == b'"' || byte == b'\'' {
355 quote = Some(byte);
356 } else if byte == b'>' {
357 return Some(pos);
358 }
359 pos += 1;
360 }
361 None
362}
363
364fn is_void(tag: &str) -> bool {
365 matches!(
366 tag,
367 "area"
368 | "base"
369 | "br"
370 | "col"
371 | "embed"
372 | "hr"
373 | "img"
374 | "input"
375 | "link"
376 | "meta"
377 | "param"
378 | "source"
379 | "track"
380 | "wbr"
381 )
382}
383
384fn strip_text(fragment: &str) -> String {
385 let mut text = String::new();
386 let mut pos = 0;
387 while pos < fragment.len() {
388 if let Some(start) = fragment[pos..].find('<') {
389 text.push_str(&fragment[pos..pos + start]);
390 let abs = pos + start;
391 match tag_end(fragment, abs) {
392 Some(end) => pos = end + 1,
393 None => break,
394 }
395 } else {
396 text.push_str(&fragment[pos..]);
397 break;
398 }
399 }
400 decode_entities(&text).trim().to_string()
401}
402
403fn decode_entities(text: &str) -> String {
404 text.replace("<", "<")
405 .replace(">", ">")
406 .replace(""", "\"")
407 .replace("'", "'")
408 .replace("'", "'")
409 .replace("&", "&")
410}
411
412#[cfg(test)]
413mod tests;