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_keys(html: &str) -> Vec<String> {
97 rows(html)
98 .into_iter()
99 .filter_map(|row| row.select_value)
100 .collect()
101}
102
103pub fn find_href_with(html: &str, needle: &str) -> Option<String> {
105 open_tags(html)
106 .into_iter()
107 .filter_map(|(_, attrs, _)| attr_value(&attrs, "href"))
108 .find(|href| href.contains(needle))
109}
110
111pub fn confirms_first(html: &str, action: &str) -> Option<bool> {
114 open_tags(html)
115 .into_iter()
116 .find(|(tag, attrs, _)| {
117 tag == "button" && attr_value(attrs, "formaction").as_deref() == Some(action)
118 })
119 .map(|(_, attrs, _)| attr_value(&attrs, "type").as_deref() == Some("button"))
120}
121
122pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
125 rows(html).into_iter().find_map(|row| {
126 let matches = row.select_value.as_deref() == Some(key)
127 || row
128 .actions
129 .view
130 .as_deref()
131 .is_some_and(|href| href_contains_key(href, key))
132 || row
133 .actions
134 .edit
135 .as_deref()
136 .is_some_and(|href| href_contains_key(href, key))
137 || row
138 .actions
139 .delete_action
140 .as_deref()
141 .is_some_and(|action| href_contains_key(action, key));
142 matches.then_some(row.actions)
143 })
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148#[non_exhaustive]
149pub enum ErrorKind {
150 Required,
152 Invalid,
154 Unavailable,
156}
157
158pub fn field_error(html: &str, name: &str) -> Option<ErrorKind> {
168 let (attrs, _) = error_slot(html, name)?;
169 Some(match attr_value(&attrs, "data-error").as_deref() {
170 Some("required") => ErrorKind::Required,
171 Some("invalid") => ErrorKind::Invalid,
172 Some("unavailable") => ErrorKind::Unavailable,
173 other => panic!("the error slot of `{name}` names no known kind: {other:?}"),
174 })
175}
176
177pub fn field_error_message(html: &str, name: &str) -> Option<String> {
179 error_slot(html, name).map(|(_, text)| text)
180}
181
182fn error_slot(html: &str, name: &str) -> Option<(Attrs, String)> {
184 let target = format!("{name}-error");
185 let (_, attrs, inner) = tags_where(html, |_, attrs| {
186 attr_value(attrs, "id").as_deref() == Some(target.as_str())
187 })
188 .into_iter()
189 .next()?;
190 let text = strip_text(&inner);
191 (!text.is_empty()).then_some((attrs, text))
192}
193
194pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
197 let (tag, _, inner) = tags_where(html, |_, attrs| {
198 attr_value(attrs, "data-filter-name").as_deref() == Some(name)
199 })
200 .into_iter()
201 .next()?;
202 if tag != "select" {
203 let options: Vec<FilterOption> = elements(&inner, "label")
204 .into_iter()
205 .filter_map(|(_, _, inner)| {
206 let (_, attrs, _) = open_tags(&inner).into_iter().find(|(tag, attrs, _)| {
207 tag == "input" && attr_value(attrs, "type").as_deref() == Some("checkbox")
208 })?;
209 Some(FilterOption {
210 value: attr_value(&attrs, "value").unwrap_or_default(),
211 label: strip_text(&inner),
212 selected: attrs.iter().any(|(name, _)| name == "checked"),
213 })
214 })
215 .collect();
216 return inner.contains("<button").then_some(options);
219 }
220 let options = elements(&inner, "option")
221 .into_iter()
222 .map(|(_, attrs, inner)| FilterOption {
223 value: attr_value(&attrs, "value").unwrap_or_default(),
224 label: strip_text(&inner),
225 selected: is_selected(&attrs),
226 })
227 .collect();
228 Some(options)
229}
230
231fn row(row_html: &str) -> Row {
232 let mut select_value = None;
233 for (tag, attrs, _) in open_tags(row_html) {
234 if tag == "input" && attr_value(&attrs, "aria-label").as_deref() == Some("Select row") {
235 select_value = attr_value(&attrs, "value");
236 break;
237 }
238 }
239 let mut cells = Vec::new();
240 for (_, _, inner) in elements(row_html, "td") {
241 cells.push(strip_text(&inner));
242 }
243 let mut view_href = None;
244 let mut edit_href = None;
245 let mut delete_action = None;
246 for (tag, attrs, _) in open_tags(row_html) {
247 match (tag.as_str(), attr_value(&attrs, "aria-label").as_deref()) {
248 ("a", Some("View")) => view_href = attr_value(&attrs, "href"),
249 ("a", Some("Edit")) => edit_href = attr_value(&attrs, "href"),
250 ("button", Some("Delete")) => delete_action = attr_value(&attrs, "formaction"),
251 _ => {}
252 }
253 }
254 Row {
255 select_value,
256 cells,
257 actions: RowActions {
258 view: view_href,
259 edit: edit_href,
260 delete_action,
261 },
262 }
263}
264
265fn href_contains_key(href: &str, key: &str) -> bool {
266 href.split(['?', '#', '/', '&'])
267 .any(|segment| segment == key)
268}
269
270fn is_selected(attrs: &Attrs) -> bool {
271 attrs.iter().any(|(name, _)| name == "selected")
272}
273
274fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
275 attrs
276 .iter()
277 .find(|(attr, _)| attr == name)
278 .and_then(|(_, value)| value.clone())
279}
280
281fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
286 open_tags(html)
287 .into_iter()
288 .filter(|(tag, attrs, _)| keep(tag, attrs))
289 .map(|(tag, attrs, open_end)| {
290 let inner = inner_html(html, &tag, open_end);
291 (tag, attrs, inner)
292 })
293 .collect()
294}
295
296fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
297 if is_void(tag) {
298 return String::new();
299 }
300 let after = &html[open_end + 1..];
303 let open = format!("<{tag}");
304 let close = format!("</{tag}>");
305 let mut depth = 1usize;
306 let mut pos = 0;
307 while let Some(at) = after[pos..].find('<') {
308 let at = pos + at;
309 let rest = &after[at..];
310 if rest.starts_with(close.as_str()) {
311 depth -= 1;
312 if depth == 0 {
313 return after[..at].to_string();
314 }
315 pos = at + close.len();
316 } else if rest.starts_with(open.as_str())
317 && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
318 {
319 depth += 1;
320 pos = at + open.len();
321 } else {
322 pos = at + 1;
323 }
324 }
325 String::new()
326}
327
328fn elements(html: &str, tag: &str) -> Vec<Element> {
329 tags_where(html, |name, _| name == tag)
330}
331
332fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
333 let mut out = Vec::new();
334 let mut pos = 0;
335 while let Some(start) = html[pos..].find('<') {
336 let abs_start = pos + start;
337 let Some(end) = tag_end(html, abs_start) else {
338 break;
339 };
340 let raw = &html[abs_start + 1..end];
341 pos = end + 1;
342 if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
343 continue;
344 }
345 let (tag, attrs) = parse_tag(raw);
346 if tag.is_empty() {
347 continue;
348 }
349 out.push((tag, attrs, end));
350 }
351 out
352}
353
354fn parse_tag(raw: &str) -> (String, Attrs) {
355 let mut attrs = Vec::new();
356 let bytes = raw.as_bytes();
357 let mut pos = 0;
358 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
359 pos += 1;
360 }
361 let tag = raw[..pos].to_ascii_lowercase();
362 while pos < bytes.len() {
363 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
364 pos += 1;
365 }
366 if pos >= bytes.len() || bytes[pos] == b'/' {
367 break;
368 }
369 let name_start = pos;
370 while pos < bytes.len()
371 && (bytes[pos].is_ascii_alphanumeric()
372 || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
373 {
374 pos += 1;
375 }
376 if name_start == pos {
377 pos += 1;
378 while pos < bytes.len() && !raw.is_char_boundary(pos) {
379 pos += 1;
380 }
381 continue;
382 }
383 let name = raw[name_start..pos].to_ascii_lowercase();
384 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
385 pos += 1;
386 }
387 if pos >= bytes.len() || bytes[pos] != b'=' {
388 attrs.push((name, None));
389 continue;
390 }
391 pos += 1;
392 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
393 pos += 1;
394 }
395 if pos >= bytes.len() {
396 attrs.push((name, Some(String::new())));
397 break;
398 }
399 let quote = bytes[pos];
400 if quote == b'"' || quote == b'\'' {
401 pos += 1;
402 let value_start = pos;
403 while pos < bytes.len() && bytes[pos] != quote {
404 pos += 1;
405 }
406 attrs.push((
407 name,
408 Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
409 ));
410 pos = (pos + 1).min(bytes.len());
411 } else {
412 let value_start = pos;
413 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
414 pos += 1;
415 }
416 attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
417 }
418 }
419 (tag, attrs)
420}
421
422fn tag_end(html: &str, start: usize) -> Option<usize> {
423 let bytes = html.as_bytes();
424 let mut quote = None;
425 let mut pos = start + 1;
426 while pos < bytes.len() {
427 let byte = bytes[pos];
428 if let Some(q) = quote {
429 if byte == q {
430 quote = None;
431 }
432 } else if byte == b'"' || byte == b'\'' {
433 quote = Some(byte);
434 } else if byte == b'>' {
435 return Some(pos);
436 }
437 pos += 1;
438 }
439 None
440}
441
442fn is_void(tag: &str) -> bool {
443 matches!(
444 tag,
445 "area"
446 | "base"
447 | "br"
448 | "col"
449 | "embed"
450 | "hr"
451 | "img"
452 | "input"
453 | "link"
454 | "meta"
455 | "param"
456 | "source"
457 | "track"
458 | "wbr"
459 )
460}
461
462fn strip_text(fragment: &str) -> String {
463 let mut text = String::new();
464 let mut pos = 0;
465 while pos < fragment.len() {
466 if let Some(start) = fragment[pos..].find('<') {
467 text.push_str(&fragment[pos..pos + start]);
468 let abs = pos + start;
469 match tag_end(fragment, abs) {
470 Some(end) => pos = end + 1,
471 None => break,
472 }
473 } else {
474 text.push_str(&fragment[pos..]);
475 break;
476 }
477 }
478 decode_entities(&text).trim().to_string()
479}
480
481fn decode_entities(text: &str) -> String {
482 text.replace("<", "<")
483 .replace(">", ">")
484 .replace(""", "\"")
485 .replace("'", "'")
486 .replace("'", "'")
487 .replace("&", "&")
488}
489
490#[cfg(test)]
491mod tests;