use tablo_core::dom::{empty, error};
pub use self::rows::{CellEdit, Group, Row, RowActions, groups, row_actions, row_keys, rows};
pub(crate) type Attrs = Vec<(String, Option<String>)>;
pub(crate) type Element = (String, Attrs, String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilterOption {
pub value: String,
pub label: String,
pub selected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerColumn {
pub name: String,
pub shown: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmptyTable {
pub reason: EmptyReason,
pub clear: Option<String>,
pub first_page: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EmptyReason {
NoRecords,
Search,
Filters,
}
pub fn empty_table(html: &str) -> Option<EmptyTable> {
let (_, attrs, inner) = tags_where(html, |_, attrs| attr_value(attrs, "data-empty").is_some())
.into_iter()
.next()?;
let link = |kind: &str| {
open_tags(&inner)
.into_iter()
.find(|(_, attrs, _)| attr_value(attrs, "data-empty-link").as_deref() == Some(kind))
.and_then(|(_, attrs, _)| attr_value(&attrs, "href"))
};
let reason = match attr_value(&attrs, "data-empty").as_deref() {
Some(empty::NONE) => EmptyReason::NoRecords,
Some(empty::SEARCH) => EmptyReason::Search,
Some(empty::FILTERS) => EmptyReason::Filters,
other => panic!("the zero-rows message names no known reason: {other:?}"),
};
Some(EmptyTable {
reason,
clear: link(empty::CLEAR_LINK),
first_page: link(empty::FIRST_PAGE_LINK),
})
}
pub fn columns(html: &str) -> Vec<String> {
open_tags(html)
.into_iter()
.filter(|(tag, _, _)| tag == "th")
.filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
.collect()
}
pub fn sortable_columns(html: &str) -> Vec<String> {
open_tags(html)
.into_iter()
.filter(|(tag, attrs, _)| tag == "th" && attr_value(attrs, "aria-sort").is_some())
.filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
.collect()
}
pub fn column_picker(html: &str) -> Option<Vec<PickerColumn>> {
let columns: Vec<PickerColumn> = open_tags(html)
.into_iter()
.filter(|(tag, attrs, _)| {
tag == "input"
&& attr_value(attrs, "type").as_deref() == Some("checkbox")
&& attr_value(attrs, "name").as_deref() == Some("column")
})
.map(|(_, attrs, _)| PickerColumn {
name: attr_value(&attrs, "value").unwrap_or_default(),
shown: attrs.iter().any(|(name, _)| name == "checked"),
})
.collect();
(!columns.is_empty()).then_some(columns)
}
pub fn find_href_with(html: &str, needle: &str) -> Option<String> {
open_tags(html)
.into_iter()
.filter_map(|(_, attrs, _)| attr_value(&attrs, "href"))
.find(|href| href.contains(needle))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
Required,
Invalid,
Unavailable,
}
pub fn field_error(html: &str, name: &str) -> Option<ErrorKind> {
let (attrs, _) = error_slot(html, name)?;
Some(match attr_value(&attrs, "data-error").as_deref() {
Some(error::REQUIRED) => ErrorKind::Required,
Some(error::INVALID) => ErrorKind::Invalid,
Some(error::UNAVAILABLE) => ErrorKind::Unavailable,
other => panic!("the error slot of `{name}` names no known kind: {other:?}"),
})
}
pub fn field_error_message(html: &str, name: &str) -> Option<String> {
error_slot(html, name).map(|(_, text)| text)
}
fn error_slot(html: &str, name: &str) -> Option<(Attrs, String)> {
let target = format!("{name}-error");
let (_, attrs, inner) = tags_where(html, |_, attrs| {
attr_value(attrs, "id").as_deref() == Some(target.as_str())
})
.into_iter()
.next()?;
let text = strip_text(&inner);
(!text.is_empty()).then_some((attrs, text))
}
pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
let (tag, _, inner) = tags_where(html, |_, attrs| {
attr_value(attrs, "data-filter-name").as_deref() == Some(name)
})
.into_iter()
.next()?;
if tag != "select" {
let options: Vec<FilterOption> = elements(&inner, "label")
.into_iter()
.filter_map(|(_, _, inner)| {
let (_, attrs, _) = open_tags(&inner).into_iter().find(|(tag, attrs, _)| {
tag == "input" && attr_value(attrs, "type").as_deref() == Some("checkbox")
})?;
Some(FilterOption {
value: attr_value(&attrs, "value").unwrap_or_default(),
label: strip_text(&inner),
selected: attrs.iter().any(|(name, _)| name == "checked"),
})
})
.collect();
return inner.contains("<button").then_some(options);
}
let options = elements(&inner, "option")
.into_iter()
.map(|(_, attrs, inner)| FilterOption {
value: attr_value(&attrs, "value").unwrap_or_default(),
label: strip_text(&inner),
selected: is_selected(&attrs),
})
.collect();
Some(options)
}
pub(crate) fn is_selected(attrs: &Attrs) -> bool {
attrs.iter().any(|(name, _)| name == "selected")
}
pub(crate) fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
attrs
.iter()
.find(|(attr, _)| attr == name)
.and_then(|(_, value)| value.clone())
}
pub(crate) fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
open_tags(html)
.into_iter()
.filter(|(tag, attrs, _)| keep(tag, attrs))
.map(|(tag, attrs, open_end)| {
let inner = inner_html(html, &tag, open_end);
(tag, attrs, inner)
})
.collect()
}
fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
if is_void(tag) {
return String::new();
}
let after = &html[open_end + 1..];
let open = format!("<{tag}");
let close = format!("</{tag}>");
let mut depth = 1usize;
let mut pos = 0;
while let Some(at) = after[pos..].find('<') {
let at = pos + at;
let rest = &after[at..];
if rest.starts_with(close.as_str()) {
depth -= 1;
if depth == 0 {
return after[..at].to_string();
}
pos = at + close.len();
} else if rest.starts_with(open.as_str())
&& rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
{
depth += 1;
pos = at + open.len();
} else {
pos = at + 1;
}
}
String::new()
}
pub(crate) fn elements(html: &str, tag: &str) -> Vec<Element> {
tags_where(html, |name, _| name == tag)
}
pub(crate) fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
let mut out = Vec::new();
let mut pos = 0;
while let Some(start) = html[pos..].find('<') {
let abs_start = pos + start;
let Some(end) = tag_end(html, abs_start) else {
break;
};
let raw = &html[abs_start + 1..end];
pos = end + 1;
if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
continue;
}
let (tag, attrs) = parse_tag(raw);
if tag.is_empty() {
continue;
}
out.push((tag, attrs, end));
}
out
}
fn parse_tag(raw: &str) -> (String, Attrs) {
let mut attrs = Vec::new();
let bytes = raw.as_bytes();
let mut pos = 0;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
pos += 1;
}
let tag = raw[..pos].to_ascii_lowercase();
while pos < bytes.len() {
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
if pos >= bytes.len() || bytes[pos] == b'/' {
break;
}
let name_start = pos;
while pos < bytes.len()
&& (bytes[pos].is_ascii_alphanumeric()
|| matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
{
pos += 1;
}
if name_start == pos {
pos += 1;
while pos < bytes.len() && !raw.is_char_boundary(pos) {
pos += 1;
}
continue;
}
let name = raw[name_start..pos].to_ascii_lowercase();
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
if pos >= bytes.len() || bytes[pos] != b'=' {
attrs.push((name, None));
continue;
}
pos += 1;
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
if pos >= bytes.len() {
attrs.push((name, Some(String::new())));
break;
}
let quote = bytes[pos];
if quote == b'"' || quote == b'\'' {
pos += 1;
let value_start = pos;
while pos < bytes.len() && bytes[pos] != quote {
pos += 1;
}
attrs.push((
name,
Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
));
pos = (pos + 1).min(bytes.len());
} else {
let value_start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
pos += 1;
}
attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
}
}
(tag, attrs)
}
fn tag_end(html: &str, start: usize) -> Option<usize> {
let bytes = html.as_bytes();
let mut quote = None;
let mut pos = start + 1;
while pos < bytes.len() {
let byte = bytes[pos];
if let Some(q) = quote {
if byte == q {
quote = None;
}
} else if byte == b'"' || byte == b'\'' {
quote = Some(byte);
} else if byte == b'>' {
return Some(pos);
}
pos += 1;
}
None
}
fn is_void(tag: &str) -> bool {
matches!(
tag,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
pub(crate) fn strip_text(fragment: &str) -> String {
let mut text = String::new();
let mut pos = 0;
while pos < fragment.len() {
if let Some(start) = fragment[pos..].find('<') {
text.push_str(&fragment[pos..pos + start]);
let abs = pos + start;
match tag_end(fragment, abs) {
Some(end) => pos = end + 1,
None => break,
}
} else {
text.push_str(&fragment[pos..]);
break;
}
}
decode_entities(&text).trim().to_string()
}
fn decode_entities(text: &str) -> String {
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&")
}
mod rows;
#[cfg(test)]
mod tests;