1use tablo_core::dom::{empty, error, row};
15
16pub(crate) type Attrs = Vec<(String, Option<String>)>;
17pub(crate) type Element = (String, Attrs, String);
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Row {
22 pub select_value: Option<String>,
24 pub cells: Vec<String>,
26 pub actions: RowActions,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RowActions {
33 pub view: Option<String>,
35 pub edit: Option<String>,
37 pub delete_action: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct FilterOption {
45 pub value: String,
47 pub label: String,
49 pub selected: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PickerColumn {
56 pub name: String,
58 pub shown: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct EmptyTable {
65 pub reason: EmptyReason,
67 pub clear: Option<String>,
69 pub first_page: Option<String>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum EmptyReason {
77 NoRecords,
79 Search,
81 Filters,
83}
84
85pub fn empty_table(html: &str) -> Option<EmptyTable> {
92 let (_, attrs, inner) = tags_where(html, |_, attrs| attr_value(attrs, "data-empty").is_some())
93 .into_iter()
94 .next()?;
95 let link = |kind: &str| {
96 open_tags(&inner)
97 .into_iter()
98 .find(|(_, attrs, _)| attr_value(attrs, "data-empty-link").as_deref() == Some(kind))
99 .and_then(|(_, attrs, _)| attr_value(&attrs, "href"))
100 };
101 let reason = match attr_value(&attrs, "data-empty").as_deref() {
102 Some(empty::NONE) => EmptyReason::NoRecords,
103 Some(empty::SEARCH) => EmptyReason::Search,
104 Some(empty::FILTERS) => EmptyReason::Filters,
105 other => panic!("the zero-rows message names no known reason: {other:?}"),
106 };
107 Some(EmptyTable {
108 reason,
109 clear: link(empty::CLEAR_LINK),
110 first_page: link(empty::FIRST_PAGE_LINK),
111 })
112}
113
114pub fn columns(html: &str) -> Vec<String> {
117 open_tags(html)
118 .into_iter()
119 .filter(|(tag, _, _)| tag == "th")
120 .filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
121 .collect()
122}
123
124pub fn column_picker(html: &str) -> Option<Vec<PickerColumn>> {
127 let columns: Vec<PickerColumn> = open_tags(html)
128 .into_iter()
129 .filter(|(tag, attrs, _)| {
130 tag == "input"
131 && attr_value(attrs, "type").as_deref() == Some("checkbox")
132 && attr_value(attrs, "name").as_deref() == Some("column")
133 })
134 .map(|(_, attrs, _)| PickerColumn {
135 name: attr_value(&attrs, "value").unwrap_or_default(),
136 shown: attrs.iter().any(|(name, _)| name == "checked"),
137 })
138 .collect();
139 (!columns.is_empty()).then_some(columns)
140}
141
142pub fn rows(html: &str) -> Vec<Row> {
144 let mut out = Vec::new();
145 for (_, attrs, inner) in elements(html, "tr") {
146 let Some(id) = attr_value(&attrs, "id") else {
147 continue;
148 };
149 if !id.starts_with("row-") {
150 continue;
151 }
152 out.push(row(&inner));
153 }
154 out
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Group {
160 pub label: String,
162 pub rows: Vec<Row>,
164}
165
166pub fn groups(html: &str) -> Vec<Group> {
169 let mut out: Vec<Group> = Vec::new();
170 for (_, attrs, inner) in elements(html, "tr") {
171 if let Some(label) = attr_value(&attrs, "data-group") {
172 out.push(Group {
173 label,
174 rows: Vec::new(),
175 });
176 } else if attr_value(&attrs, "id").is_some_and(|id| id.starts_with("row-"))
177 && let Some(group) = out.last_mut()
178 {
179 group.rows.push(row(&inner));
180 }
181 }
182 out
183}
184
185pub fn row_keys(html: &str) -> Vec<String> {
188 rows(html)
189 .into_iter()
190 .filter_map(|row| row.select_value)
191 .collect()
192}
193
194pub fn find_href_with(html: &str, needle: &str) -> Option<String> {
196 open_tags(html)
197 .into_iter()
198 .filter_map(|(_, attrs, _)| attr_value(&attrs, "href"))
199 .find(|href| href.contains(needle))
200}
201
202pub fn confirms_first(html: &str, action: &str) -> Option<bool> {
205 open_tags(html)
206 .into_iter()
207 .find(|(tag, attrs, _)| {
208 tag == "button" && attr_value(attrs, "formaction").as_deref() == Some(action)
209 })
210 .map(|(_, attrs, _)| attr_value(&attrs, "type").as_deref() == Some("button"))
211}
212
213pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
216 rows(html).into_iter().find_map(|row| {
217 let matches = row.select_value.as_deref() == Some(key)
218 || row
219 .actions
220 .view
221 .as_deref()
222 .is_some_and(|href| href_contains_key(href, key))
223 || row
224 .actions
225 .edit
226 .as_deref()
227 .is_some_and(|href| href_contains_key(href, key))
228 || row
229 .actions
230 .delete_action
231 .as_deref()
232 .is_some_and(|action| href_contains_key(action, key));
233 matches.then_some(row.actions)
234 })
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239#[non_exhaustive]
240pub enum ErrorKind {
241 Required,
243 Invalid,
245 Unavailable,
247}
248
249pub fn field_error(html: &str, name: &str) -> Option<ErrorKind> {
259 let (attrs, _) = error_slot(html, name)?;
260 Some(match attr_value(&attrs, "data-error").as_deref() {
261 Some(error::REQUIRED) => ErrorKind::Required,
262 Some(error::INVALID) => ErrorKind::Invalid,
263 Some(error::UNAVAILABLE) => ErrorKind::Unavailable,
264 other => panic!("the error slot of `{name}` names no known kind: {other:?}"),
265 })
266}
267
268pub fn field_error_message(html: &str, name: &str) -> Option<String> {
270 error_slot(html, name).map(|(_, text)| text)
271}
272
273fn error_slot(html: &str, name: &str) -> Option<(Attrs, String)> {
275 let target = format!("{name}-error");
276 let (_, attrs, inner) = tags_where(html, |_, attrs| {
277 attr_value(attrs, "id").as_deref() == Some(target.as_str())
278 })
279 .into_iter()
280 .next()?;
281 let text = strip_text(&inner);
282 (!text.is_empty()).then_some((attrs, text))
283}
284
285pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
288 let (tag, _, inner) = tags_where(html, |_, attrs| {
289 attr_value(attrs, "data-filter-name").as_deref() == Some(name)
290 })
291 .into_iter()
292 .next()?;
293 if tag != "select" {
294 let options: Vec<FilterOption> = elements(&inner, "label")
295 .into_iter()
296 .filter_map(|(_, _, inner)| {
297 let (_, attrs, _) = open_tags(&inner).into_iter().find(|(tag, attrs, _)| {
298 tag == "input" && attr_value(attrs, "type").as_deref() == Some("checkbox")
299 })?;
300 Some(FilterOption {
301 value: attr_value(&attrs, "value").unwrap_or_default(),
302 label: strip_text(&inner),
303 selected: attrs.iter().any(|(name, _)| name == "checked"),
304 })
305 })
306 .collect();
307 return inner.contains("<button").then_some(options);
310 }
311 let options = elements(&inner, "option")
312 .into_iter()
313 .map(|(_, attrs, inner)| FilterOption {
314 value: attr_value(&attrs, "value").unwrap_or_default(),
315 label: strip_text(&inner),
316 selected: is_selected(&attrs),
317 })
318 .collect();
319 Some(options)
320}
321
322pub fn nav_badge(html: &str, href: &str) -> Option<String> {
325 let menu = |name: &'static str| {
326 move |_: &str, attrs: &Attrs| attr_value(attrs, "data-sidebar").as_deref() == Some(name)
327 };
328 let (_, _, entry) = tags_where(html, menu("menu-item"))
329 .into_iter()
330 .find(|(_, _, inner)| {
331 open_tags(inner).iter().any(|(tag, attrs, _)| {
332 tag == "a" && attr_value(attrs, "href").as_deref() == Some(href)
333 })
334 })?;
335 let (_, _, badge) = tags_where(&entry, menu("menu-badge")).into_iter().next()?;
336 Some(strip_text(&badge))
337}
338
339fn row(row_html: &str) -> Row {
340 let mut select_value = None;
341 for (tag, attrs, _) in open_tags(row_html) {
342 if tag == "input" && attr_value(&attrs, "data-row-select").is_some() {
343 select_value = attr_value(&attrs, "value");
344 break;
345 }
346 }
347 let mut cells = Vec::new();
348 for (_, _, inner) in elements(row_html, "td") {
349 cells.push(strip_text(&inner));
350 }
351 let mut view_href = None;
352 let mut edit_href = None;
353 let mut delete_action = None;
354 for (_, attrs, _) in open_tags(row_html) {
355 match attr_value(&attrs, "data-row-action").as_deref() {
356 Some(row::VIEW) => view_href = attr_value(&attrs, "href"),
357 Some(row::EDIT) => edit_href = attr_value(&attrs, "href"),
358 Some(row::DELETE) => delete_action = attr_value(&attrs, "formaction"),
359 _ => {}
360 }
361 }
362 Row {
363 select_value,
364 cells,
365 actions: RowActions {
366 view: view_href,
367 edit: edit_href,
368 delete_action,
369 },
370 }
371}
372
373fn href_contains_key(href: &str, key: &str) -> bool {
374 href.split(['?', '#', '/', '&'])
375 .any(|segment| segment == key)
376}
377
378fn is_selected(attrs: &Attrs) -> bool {
379 attrs.iter().any(|(name, _)| name == "selected")
380}
381
382pub(crate) fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
383 attrs
384 .iter()
385 .find(|(attr, _)| attr == name)
386 .and_then(|(_, value)| value.clone())
387}
388
389pub(crate) fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
394 open_tags(html)
395 .into_iter()
396 .filter(|(tag, attrs, _)| keep(tag, attrs))
397 .map(|(tag, attrs, open_end)| {
398 let inner = inner_html(html, &tag, open_end);
399 (tag, attrs, inner)
400 })
401 .collect()
402}
403
404fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
405 if is_void(tag) {
406 return String::new();
407 }
408 let after = &html[open_end + 1..];
411 let open = format!("<{tag}");
412 let close = format!("</{tag}>");
413 let mut depth = 1usize;
414 let mut pos = 0;
415 while let Some(at) = after[pos..].find('<') {
416 let at = pos + at;
417 let rest = &after[at..];
418 if rest.starts_with(close.as_str()) {
419 depth -= 1;
420 if depth == 0 {
421 return after[..at].to_string();
422 }
423 pos = at + close.len();
424 } else if rest.starts_with(open.as_str())
425 && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
426 {
427 depth += 1;
428 pos = at + open.len();
429 } else {
430 pos = at + 1;
431 }
432 }
433 String::new()
434}
435
436fn elements(html: &str, tag: &str) -> Vec<Element> {
437 tags_where(html, |name, _| name == tag)
438}
439
440fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
441 let mut out = Vec::new();
442 let mut pos = 0;
443 while let Some(start) = html[pos..].find('<') {
444 let abs_start = pos + start;
445 let Some(end) = tag_end(html, abs_start) else {
446 break;
447 };
448 let raw = &html[abs_start + 1..end];
449 pos = end + 1;
450 if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
451 continue;
452 }
453 let (tag, attrs) = parse_tag(raw);
454 if tag.is_empty() {
455 continue;
456 }
457 out.push((tag, attrs, end));
458 }
459 out
460}
461
462fn parse_tag(raw: &str) -> (String, Attrs) {
463 let mut attrs = Vec::new();
464 let bytes = raw.as_bytes();
465 let mut pos = 0;
466 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
467 pos += 1;
468 }
469 let tag = raw[..pos].to_ascii_lowercase();
470 while pos < bytes.len() {
471 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
472 pos += 1;
473 }
474 if pos >= bytes.len() || bytes[pos] == b'/' {
475 break;
476 }
477 let name_start = pos;
478 while pos < bytes.len()
479 && (bytes[pos].is_ascii_alphanumeric()
480 || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
481 {
482 pos += 1;
483 }
484 if name_start == pos {
485 pos += 1;
486 while pos < bytes.len() && !raw.is_char_boundary(pos) {
487 pos += 1;
488 }
489 continue;
490 }
491 let name = raw[name_start..pos].to_ascii_lowercase();
492 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
493 pos += 1;
494 }
495 if pos >= bytes.len() || bytes[pos] != b'=' {
496 attrs.push((name, None));
497 continue;
498 }
499 pos += 1;
500 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
501 pos += 1;
502 }
503 if pos >= bytes.len() {
504 attrs.push((name, Some(String::new())));
505 break;
506 }
507 let quote = bytes[pos];
508 if quote == b'"' || quote == b'\'' {
509 pos += 1;
510 let value_start = pos;
511 while pos < bytes.len() && bytes[pos] != quote {
512 pos += 1;
513 }
514 attrs.push((
515 name,
516 Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
517 ));
518 pos = (pos + 1).min(bytes.len());
519 } else {
520 let value_start = pos;
521 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
522 pos += 1;
523 }
524 attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
525 }
526 }
527 (tag, attrs)
528}
529
530fn tag_end(html: &str, start: usize) -> Option<usize> {
531 let bytes = html.as_bytes();
532 let mut quote = None;
533 let mut pos = start + 1;
534 while pos < bytes.len() {
535 let byte = bytes[pos];
536 if let Some(q) = quote {
537 if byte == q {
538 quote = None;
539 }
540 } else if byte == b'"' || byte == b'\'' {
541 quote = Some(byte);
542 } else if byte == b'>' {
543 return Some(pos);
544 }
545 pos += 1;
546 }
547 None
548}
549
550fn is_void(tag: &str) -> bool {
551 matches!(
552 tag,
553 "area"
554 | "base"
555 | "br"
556 | "col"
557 | "embed"
558 | "hr"
559 | "img"
560 | "input"
561 | "link"
562 | "meta"
563 | "param"
564 | "source"
565 | "track"
566 | "wbr"
567 )
568}
569
570pub(crate) fn strip_text(fragment: &str) -> String {
571 let mut text = String::new();
572 let mut pos = 0;
573 while pos < fragment.len() {
574 if let Some(start) = fragment[pos..].find('<') {
575 text.push_str(&fragment[pos..pos + start]);
576 let abs = pos + start;
577 match tag_end(fragment, abs) {
578 Some(end) => pos = end + 1,
579 None => break,
580 }
581 } else {
582 text.push_str(&fragment[pos..]);
583 break;
584 }
585 }
586 decode_entities(&text).trim().to_string()
587}
588
589fn decode_entities(text: &str) -> String {
590 text.replace("<", "<")
591 .replace(">", ">")
592 .replace(""", "\"")
593 .replace("'", "'")
594 .replace("'", "'")
595 .replace("&", "&")
596}
597
598#[cfg(test)]
599mod tests;