1use tablo_core::dom::{empty, error};
15
16pub use self::rows::{CellEdit, Group, Row, RowActions, groups, row_actions, row_keys, rows};
17
18pub(crate) type Attrs = Vec<(String, Option<String>)>;
19pub(crate) type Element = (String, Attrs, String);
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FilterOption {
24 pub value: String,
26 pub label: String,
28 pub selected: bool,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PickerColumn {
35 pub name: String,
37 pub shown: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EmptyTable {
44 pub reason: EmptyReason,
46 pub clear: Option<String>,
48 pub first_page: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum EmptyReason {
56 NoRecords,
58 Search,
60 Filters,
62}
63
64pub fn empty_table(html: &str) -> Option<EmptyTable> {
71 let (_, attrs, inner) = tags_where(html, |_, attrs| attr_value(attrs, "data-empty").is_some())
72 .into_iter()
73 .next()?;
74 let link = |kind: &str| {
75 open_tags(&inner)
76 .into_iter()
77 .find(|(_, attrs, _)| attr_value(attrs, "data-empty-link").as_deref() == Some(kind))
78 .and_then(|(_, attrs, _)| attr_value(&attrs, "href"))
79 };
80 let reason = match attr_value(&attrs, "data-empty").as_deref() {
81 Some(empty::NONE) => EmptyReason::NoRecords,
82 Some(empty::SEARCH) => EmptyReason::Search,
83 Some(empty::FILTERS) => EmptyReason::Filters,
84 other => panic!("the zero-rows message names no known reason: {other:?}"),
85 };
86 Some(EmptyTable {
87 reason,
88 clear: link(empty::CLEAR_LINK),
89 first_page: link(empty::FIRST_PAGE_LINK),
90 })
91}
92
93pub fn columns(html: &str) -> Vec<String> {
96 open_tags(html)
97 .into_iter()
98 .filter(|(tag, _, _)| tag == "th")
99 .filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
100 .collect()
101}
102
103pub fn sortable_columns(html: &str) -> Vec<String> {
106 open_tags(html)
107 .into_iter()
108 .filter(|(tag, attrs, _)| tag == "th" && attr_value(attrs, "aria-sort").is_some())
109 .filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
110 .collect()
111}
112
113pub fn column_picker(html: &str) -> Option<Vec<PickerColumn>> {
116 let columns: Vec<PickerColumn> = open_tags(html)
117 .into_iter()
118 .filter(|(tag, attrs, _)| {
119 tag == "input"
120 && attr_value(attrs, "type").as_deref() == Some("checkbox")
121 && attr_value(attrs, "name").as_deref() == Some("column")
122 })
123 .map(|(_, attrs, _)| PickerColumn {
124 name: attr_value(&attrs, "value").unwrap_or_default(),
125 shown: attrs.iter().any(|(name, _)| name == "checked"),
126 })
127 .collect();
128 (!columns.is_empty()).then_some(columns)
129}
130
131pub fn find_href_with(html: &str, needle: &str) -> Option<String> {
133 open_tags(html)
134 .into_iter()
135 .filter_map(|(_, attrs, _)| attr_value(&attrs, "href"))
136 .find(|href| href.contains(needle))
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum ErrorKind {
143 Required,
145 Invalid,
147 Unavailable,
149}
150
151pub fn field_error(html: &str, name: &str) -> Option<ErrorKind> {
161 let (attrs, _) = error_slot(html, name)?;
162 Some(match attr_value(&attrs, "data-error").as_deref() {
163 Some(error::REQUIRED) => ErrorKind::Required,
164 Some(error::INVALID) => ErrorKind::Invalid,
165 Some(error::UNAVAILABLE) => ErrorKind::Unavailable,
166 other => panic!("the error slot of `{name}` names no known kind: {other:?}"),
167 })
168}
169
170pub fn field_error_message(html: &str, name: &str) -> Option<String> {
172 error_slot(html, name).map(|(_, text)| text)
173}
174
175fn error_slot(html: &str, name: &str) -> Option<(Attrs, String)> {
177 let target = format!("{name}-error");
178 let (_, attrs, inner) = tags_where(html, |_, attrs| {
179 attr_value(attrs, "id").as_deref() == Some(target.as_str())
180 })
181 .into_iter()
182 .next()?;
183 let text = strip_text(&inner);
184 (!text.is_empty()).then_some((attrs, text))
185}
186
187pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
190 let (tag, _, inner) = tags_where(html, |_, attrs| {
191 attr_value(attrs, "data-filter-name").as_deref() == Some(name)
192 })
193 .into_iter()
194 .next()?;
195 if tag != "select" {
196 let options: Vec<FilterOption> = elements(&inner, "label")
197 .into_iter()
198 .filter_map(|(_, _, inner)| {
199 let (_, attrs, _) = open_tags(&inner).into_iter().find(|(tag, attrs, _)| {
200 tag == "input" && attr_value(attrs, "type").as_deref() == Some("checkbox")
201 })?;
202 Some(FilterOption {
203 value: attr_value(&attrs, "value").unwrap_or_default(),
204 label: strip_text(&inner),
205 selected: attrs.iter().any(|(name, _)| name == "checked"),
206 })
207 })
208 .collect();
209 return inner.contains("<button").then_some(options);
212 }
213 let options = elements(&inner, "option")
214 .into_iter()
215 .map(|(_, attrs, inner)| FilterOption {
216 value: attr_value(&attrs, "value").unwrap_or_default(),
217 label: strip_text(&inner),
218 selected: is_selected(&attrs),
219 })
220 .collect();
221 Some(options)
222}
223
224pub(crate) fn is_selected(attrs: &Attrs) -> bool {
225 attrs.iter().any(|(name, _)| name == "selected")
226}
227
228pub(crate) fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
229 attrs
230 .iter()
231 .find(|(attr, _)| attr == name)
232 .and_then(|(_, value)| value.clone())
233}
234
235pub(crate) fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
240 open_tags(html)
241 .into_iter()
242 .filter(|(tag, attrs, _)| keep(tag, attrs))
243 .map(|(tag, attrs, open_end)| {
244 let inner = inner_html(html, &tag, open_end);
245 (tag, attrs, inner)
246 })
247 .collect()
248}
249
250fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
251 if is_void(tag) {
252 return String::new();
253 }
254 let after = &html[open_end + 1..];
257 let open = format!("<{tag}");
258 let close = format!("</{tag}>");
259 let mut depth = 1usize;
260 let mut pos = 0;
261 while let Some(at) = after[pos..].find('<') {
262 let at = pos + at;
263 let rest = &after[at..];
264 if rest.starts_with(close.as_str()) {
265 depth -= 1;
266 if depth == 0 {
267 return after[..at].to_string();
268 }
269 pos = at + close.len();
270 } else if rest.starts_with(open.as_str())
271 && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
272 {
273 depth += 1;
274 pos = at + open.len();
275 } else {
276 pos = at + 1;
277 }
278 }
279 String::new()
280}
281
282pub(crate) fn elements(html: &str, tag: &str) -> Vec<Element> {
283 tags_where(html, |name, _| name == tag)
284}
285
286pub(crate) fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
287 let mut out = Vec::new();
288 let mut pos = 0;
289 while let Some(start) = html[pos..].find('<') {
290 let abs_start = pos + start;
291 let Some(end) = tag_end(html, abs_start) else {
292 break;
293 };
294 let raw = &html[abs_start + 1..end];
295 pos = end + 1;
296 if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
297 continue;
298 }
299 let (tag, attrs) = parse_tag(raw);
300 if tag.is_empty() {
301 continue;
302 }
303 out.push((tag, attrs, end));
304 }
305 out
306}
307
308fn parse_tag(raw: &str) -> (String, Attrs) {
309 let mut attrs = Vec::new();
310 let bytes = raw.as_bytes();
311 let mut pos = 0;
312 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
313 pos += 1;
314 }
315 let tag = raw[..pos].to_ascii_lowercase();
316 while pos < bytes.len() {
317 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
318 pos += 1;
319 }
320 if pos >= bytes.len() || bytes[pos] == b'/' {
321 break;
322 }
323 let name_start = pos;
324 while pos < bytes.len()
325 && (bytes[pos].is_ascii_alphanumeric()
326 || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
327 {
328 pos += 1;
329 }
330 if name_start == pos {
331 pos += 1;
332 while pos < bytes.len() && !raw.is_char_boundary(pos) {
333 pos += 1;
334 }
335 continue;
336 }
337 let name = raw[name_start..pos].to_ascii_lowercase();
338 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
339 pos += 1;
340 }
341 if pos >= bytes.len() || bytes[pos] != b'=' {
342 attrs.push((name, None));
343 continue;
344 }
345 pos += 1;
346 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
347 pos += 1;
348 }
349 if pos >= bytes.len() {
350 attrs.push((name, Some(String::new())));
351 break;
352 }
353 let quote = bytes[pos];
354 if quote == b'"' || quote == b'\'' {
355 pos += 1;
356 let value_start = pos;
357 while pos < bytes.len() && bytes[pos] != quote {
358 pos += 1;
359 }
360 attrs.push((
361 name,
362 Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
363 ));
364 pos = (pos + 1).min(bytes.len());
365 } else {
366 let value_start = pos;
367 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
368 pos += 1;
369 }
370 attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
371 }
372 }
373 (tag, attrs)
374}
375
376fn tag_end(html: &str, start: usize) -> Option<usize> {
377 let bytes = html.as_bytes();
378 let mut quote = None;
379 let mut pos = start + 1;
380 while pos < bytes.len() {
381 let byte = bytes[pos];
382 if let Some(q) = quote {
383 if byte == q {
384 quote = None;
385 }
386 } else if byte == b'"' || byte == b'\'' {
387 quote = Some(byte);
388 } else if byte == b'>' {
389 return Some(pos);
390 }
391 pos += 1;
392 }
393 None
394}
395
396fn is_void(tag: &str) -> bool {
397 matches!(
398 tag,
399 "area"
400 | "base"
401 | "br"
402 | "col"
403 | "embed"
404 | "hr"
405 | "img"
406 | "input"
407 | "link"
408 | "meta"
409 | "param"
410 | "source"
411 | "track"
412 | "wbr"
413 )
414}
415
416pub(crate) fn strip_text(fragment: &str) -> String {
417 let mut text = String::new();
418 let mut pos = 0;
419 while pos < fragment.len() {
420 if let Some(start) = fragment[pos..].find('<') {
421 text.push_str(&fragment[pos..pos + start]);
422 let abs = pos + start;
423 match tag_end(fragment, abs) {
424 Some(end) => pos = end + 1,
425 None => break,
426 }
427 } else {
428 text.push_str(&fragment[pos..]);
429 break;
430 }
431 }
432 decode_entities(&text).trim().to_string()
433}
434
435fn decode_entities(text: &str) -> String {
436 text.replace("<", "<")
437 .replace(">", ">")
438 .replace(""", "\"")
439 .replace("'", "'")
440 .replace("'", "'")
441 .replace("&", "&")
442}
443
444mod rows;
445#[cfg(test)]
446mod tests;