1mod bell;
7mod choice_create;
8mod controls;
9mod detail;
10pub mod dom;
11mod ignored;
12mod impersonation;
13mod import;
14mod list_tabs;
15mod page_filters;
16mod rich_text;
17mod search;
18mod sections;
19mod sidebar;
20mod summaries;
21mod tabs;
22mod widgets;
23mod wizard;
24
25pub use bell::{Bell, BellNotification, bell};
26pub use choice_create::{CreateOptionLink, Picked, create_option_links, picked, posts_picking};
27pub use controls::{
28 DisabledAction, action_badge, action_label, action_tooltip, confirms_first, disabled_actions,
29 selects_matching,
30};
31pub use detail::{DetailEntry, RecordNavLink, detail_entries, record_navigation};
32pub use dom::{
33 CellEdit, EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, PickerColumn, Row,
34 RowActions, column_picker, columns, empty_table, field_error, field_error_message,
35 filter_options, find_href_with, groups, row_actions, row_keys, rows, sortable_columns,
36};
37use http_body_util::BodyExt;
38pub use ignored::{IgnoredFilter, IgnoredReason, ignored_filters};
39pub use impersonation::{ImpersonationBanner, impersonate_buttons, impersonation_banner};
40pub use import::{ImportError, ImportRefusal, import_columns, import_errors, import_refusal};
41pub use list_tabs::{TabLink, tab_links};
42pub use page_filters::{PageFilterBar, page_filter_bar};
43pub use rich_text::rich_text_values;
44pub use search::{SearchGroup, SearchResult, search_box, search_results};
45pub use sections::{Section, sections};
46pub use sidebar::{
47 NavGroup, area_back_link, nav_badge, nav_groups, nav_links, tenant_page_link, trail,
48};
49pub use summaries::{SummaryFigure, summaries};
50use tablo_core::{Notification, auth::SESSION_COOKIE_PREFIX};
51pub use tabs::{Tab, tabs};
52use topcoat::router::Body;
53pub use widgets::{
54 ChartData, ChartSeries, RecordTableData, StatTile, charts, record_tables, stats, widgets,
55};
56pub use wizard::{WizardStep, wizard_steps};
57
58pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
60 let mut jar = String::new();
61 for (name, value) in cookies {
62 if !jar.is_empty() {
63 jar.push_str("; ");
64 }
65 jar.push_str(name);
66 jar.push('=');
67 jar.push_str(value);
68 }
69 (!jar.is_empty()).then_some(jar)
70}
71
72pub fn page_language(html: &str) -> Option<String> {
74 let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
75 .into_iter()
76 .next()?;
77 dom::attr_value(&attrs, "lang")
78}
79
80pub fn robots(html: &str) -> Option<String> {
82 let (_, attrs, _) = dom::tags_where(html, |tag, attrs| {
83 tag == "meta" && dom::attr_value(attrs, "name").as_deref() == Some("robots")
84 })
85 .into_iter()
86 .next()?;
87 dom::attr_value(&attrs, "content")
88}
89
90pub fn inline_scripts(html: &str) -> Vec<String> {
92 dom::tags_where(html, |tag, attrs| {
93 tag == "script" && dom::attr_value(attrs, "src").is_none()
94 })
95 .into_iter()
96 .map(|(_, _, inner)| inner)
97 .collect()
98}
99
100pub fn page_title(html: &str) -> Option<String> {
102 let (_, _, inner) = dom::tags_where(html, |_, attrs| {
103 dom::attr_value(attrs, "data-page-title").is_some()
104 })
105 .into_iter()
106 .next()?;
107 Some(dom::strip_text(&inner))
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct LanguageChoice {
113 pub locale: String,
115 pub label: String,
117 pub current: bool,
119}
120
121pub fn language_choices(html: &str) -> Vec<LanguageChoice> {
124 dom::tags_where(html, |_, attrs| {
125 dom::attr_value(attrs, "data-locale").is_some()
126 })
127 .into_iter()
128 .map(|(_, attrs, inner)| LanguageChoice {
129 locale: dom::attr_value(&attrs, "data-locale").unwrap_or_default(),
130 label: dom::strip_text(&inner),
131 current: dom::attr_value(&attrs, "aria-current").as_deref() == Some("true"),
132 })
133 .collect()
134}
135
136pub fn page_direction(html: &str) -> Option<String> {
138 let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
139 .into_iter()
140 .next()?;
141 dom::attr_value(&attrs, "dir")
142}
143
144pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
146 response
147 .into_body()
148 .collect()
149 .await
150 .expect("collect body")
151 .to_bytes()
152 .to_vec()
153}
154
155pub async fn body_string(response: http::Response<Body>) -> String {
157 String::from_utf8_lossy(&body_bytes(response).await).into_owned()
158}
159
160pub fn form_body(pairs: &[(&str, &str)]) -> String {
162 let mut serializer = form_urlencoded::Serializer::new(String::new());
163 for (key, value) in pairs {
164 serializer.append_pair(key, value);
165 }
166 serializer.finish()
167}
168
169pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
175 let mut body = String::new();
176 for (name, filename, content) in parts {
177 body.push_str(&format!(
178 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
179 ));
180 if let Some(filename) = filename {
181 body.push_str(&format!("; filename=\"{filename}\""));
182 }
183 body.push_str("\r\n\r\n");
184 body.push_str(content);
185 body.push_str("\r\n");
186 }
187 body.push_str(&format!("--{boundary}--\r\n"));
188 body
189}
190
191pub fn input_value(html: &str, name: &str) -> Option<String> {
196 input_values(html, name).pop()
197}
198
199pub fn input_values(html: &str, name: &str) -> Vec<String> {
202 let double = format!("name=\"{name}\"");
203 let single = format!("name='{name}'");
204 let mut posted = Vec::new();
205 for tag in html.split('<').skip(1) {
206 if !tag.starts_with("input") || (!tag.contains(&double) && !tag.contains(&single)) {
207 continue;
208 }
209 let Some(end) = tag.find('>') else {
210 continue;
211 };
212 let attrs = &tag[..end];
213 let checkable = [
214 "type=\"radio\"",
215 "type=\"checkbox\"",
216 "type='radio'",
217 "type='checkbox'",
218 ]
219 .iter()
220 .any(|kind| attrs.contains(kind));
221 if checkable && !has_attribute(attrs, "checked") {
222 continue;
223 }
224 for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
225 if let Some(start) = attrs.find(prefix) {
226 let rest = &attrs[start + prefix.len()..];
227 if let Some(end) = rest.find(term) {
228 let raw = &rest[..end];
230 posted.push(raw.replace(""", "\"").replace("'", "'"));
231 break;
232 }
233 }
234 }
235 }
236 posted
237}
238
239fn has_attribute(attrs: &str, name: &str) -> bool {
242 let mut rest = attrs;
243 while let Some(at) = rest.find(|c: char| !c.is_whitespace()) {
244 rest = &rest[at..];
245 let end = rest
246 .find(|c: char| c.is_whitespace() || c == '=' || c == '/')
247 .unwrap_or(rest.len());
248 if &rest[..end] == name {
249 return true;
250 }
251 rest = &rest[end..];
252 if let Some(value) = rest.strip_prefix('=') {
253 rest = match value.chars().next() {
254 Some(quote @ ('"' | '\'')) => value[1..]
255 .find(quote)
256 .map_or("", |close| &value[close + 2..]),
257 _ => value
258 .find(char::is_whitespace)
259 .map_or("", |space| &value[space..]),
260 };
261 } else if let Some(after) = rest.strip_prefix('/') {
262 rest = after;
263 }
264 }
265 false
266}
267
268pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
270 response
271 .headers()
272 .get_all(http::header::SET_COOKIE)
273 .iter()
274 .filter_map(|value| value.to_str().ok())
275 .filter_map(|value| value.split(';').next())
276 .filter_map(|pair| pair.split_once('='))
277 .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
278 .collect()
279}
280
281pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
283 response
284 .headers()
285 .get_all(http::header::SET_COOKIE)
286 .iter()
287 .filter_map(|value| value.to_str().ok())
288 .find(|value| value.starts_with(&format!("{name}=")))
289 .map(str::to_string)
290}
291
292pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
294 response_cookies(response)
295 .into_iter()
296 .find(|(name, _)| name.starts_with(SESSION_COOKIE_PREFIX))
297 .map(|(_, value)| value)
298}
299
300pub fn notification(response: &http::Response<Body>) -> Option<Notification> {
302 notification_in(&response_cookies(response))
303}
304
305pub(crate) fn notification_in(cookies: &[(String, String)]) -> Option<Notification> {
307 let (_, value) = cookies
308 .iter()
309 .find(|(name, _)| name.ends_with("tablo_notification"))?;
310 let json = percent_encoding::percent_decode_str(value)
311 .decode_utf8()
312 .ok()?;
313 serde_json::from_str(&json).ok()
314}
315
316pub fn stale_form(html: &str) -> bool {
319 !dom::tags_where(html, |_, attrs| {
320 attrs.iter().any(|(attr, _)| attr == "data-stale")
321 })
322 .is_empty()
323}
324
325pub fn declined_message(html: &str) -> Option<String> {
328 let (_, _, inner) = dom::tags_where(html, |_, attrs| {
329 attrs.iter().any(|(attr, _)| attr == "data-declined")
330 })
331 .into_iter()
332 .next()?;
333 Some(dom::strip_text(&inner))
334}
335
336pub fn external_sign_out(html: &str) -> Option<String> {
339 let (_, attrs, _) = dom::tags_where(html, |_, attrs| {
340 attrs
341 .iter()
342 .any(|(attr, _)| attr == "data-external-sign-out")
343 })
344 .into_iter()
345 .next()?;
346 attrs.into_iter().find(|(attr, _)| attr == "href")?.1
347}
348
349pub fn refreshes_to(html: &str) -> Option<String> {
351 let (_, attrs, _) = dom::tags_where(html, |name, attrs| {
352 name == "meta" && attrs.iter().any(|(attr, _)| attr == "http-equiv")
353 })
354 .into_iter()
355 .next()?;
356 let content = attrs.into_iter().find(|(attr, _)| attr == "content")?.1?;
357 content.split_once("url=").map(|(_, url)| url.to_string())
358}
359
360pub fn dirty_form(html: &str) -> bool {
363 !dom::tags_where(html, |name, attrs| {
364 name == "form" && attrs.iter().any(|(attr, _)| attr == "data-form-dirty")
365 })
366 .is_empty()
367}