1mod bell;
6pub mod dom;
7mod ignored;
8mod import;
9mod rich_text;
10mod search;
11mod tabs;
12
13pub use bell::{Bell, BellNotification, bell};
14pub use dom::{
15 EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, PickerColumn, Row, RowActions,
16 column_picker, columns, confirms_first, empty_table, field_error, field_error_message,
17 filter_options, find_href_with, groups, nav_badge, row_actions, row_keys, rows,
18};
19use http_body_util::BodyExt;
20pub use ignored::{IgnoredFilter, IgnoredReason, ignored_filters};
21pub use import::{ImportError, ImportRefusal, import_columns, import_errors, import_refusal};
22pub use rich_text::rich_text_values;
23pub use search::{SearchGroup, SearchResult, search_box, search_results};
24use tablo_core::Notification;
25pub use tabs::{Tab, tabs};
26use topcoat::router::Body;
27
28pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
30 let mut jar = String::new();
31 for (name, value) in cookies {
32 if !jar.is_empty() {
33 jar.push_str("; ");
34 }
35 jar.push_str(name);
36 jar.push('=');
37 jar.push_str(value);
38 }
39 (!jar.is_empty()).then_some(jar)
40}
41
42pub fn page_language(html: &str) -> Option<String> {
44 let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
45 .into_iter()
46 .next()?;
47 dom::attr_value(&attrs, "lang")
48}
49
50pub fn page_direction(html: &str) -> Option<String> {
52 let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
53 .into_iter()
54 .next()?;
55 dom::attr_value(&attrs, "dir")
56}
57
58pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
60 response
61 .into_body()
62 .collect()
63 .await
64 .expect("collect body")
65 .to_bytes()
66 .to_vec()
67}
68
69pub async fn body_string(response: http::Response<Body>) -> String {
71 String::from_utf8_lossy(&body_bytes(response).await).into_owned()
72}
73
74pub fn form_body(pairs: &[(&str, &str)]) -> String {
76 let mut serializer = form_urlencoded::Serializer::new(String::new());
77 for (key, value) in pairs {
78 serializer.append_pair(key, value);
79 }
80 serializer.finish()
81}
82
83pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
89 let mut body = String::new();
90 for (name, filename, content) in parts {
91 body.push_str(&format!(
92 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
93 ));
94 if let Some(filename) = filename {
95 body.push_str(&format!("; filename=\"{filename}\""));
96 }
97 body.push_str("\r\n\r\n");
98 body.push_str(content);
99 body.push_str("\r\n");
100 }
101 body.push_str(&format!("--{boundary}--\r\n"));
102 body
103}
104
105pub fn input_value(html: &str, name: &str) -> Option<String> {
110 let double = format!("name=\"{name}\"");
111 let single = format!("name='{name}'");
112 let mut posted = None;
113 for tag in html.split('<').skip(1) {
114 if !tag.starts_with("input") || (!tag.contains(&double) && !tag.contains(&single)) {
115 continue;
116 }
117 let Some(end) = tag.find('>') else {
118 continue;
119 };
120 let attrs = &tag[..end];
121 let checkable = [
122 "type=\"radio\"",
123 "type=\"checkbox\"",
124 "type='radio'",
125 "type='checkbox'",
126 ]
127 .iter()
128 .any(|kind| attrs.contains(kind));
129 if checkable && !has_attribute(attrs, "checked") {
130 continue;
131 }
132 for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
133 if let Some(start) = attrs.find(prefix) {
134 let rest = &attrs[start + prefix.len()..];
135 if let Some(end) = rest.find(term) {
136 let raw = &rest[..end];
138 posted = Some(raw.replace(""", "\"").replace("'", "'"));
139 break;
140 }
141 }
142 }
143 }
144 posted
145}
146
147fn has_attribute(attrs: &str, name: &str) -> bool {
150 let mut rest = attrs;
151 while let Some(at) = rest.find(|c: char| !c.is_whitespace()) {
152 rest = &rest[at..];
153 let end = rest
154 .find(|c: char| c.is_whitespace() || c == '=' || c == '/')
155 .unwrap_or(rest.len());
156 if &rest[..end] == name {
157 return true;
158 }
159 rest = &rest[end..];
160 if let Some(value) = rest.strip_prefix('=') {
161 rest = match value.chars().next() {
162 Some(quote @ ('"' | '\'')) => value[1..]
163 .find(quote)
164 .map_or("", |close| &value[close + 2..]),
165 _ => value
166 .find(char::is_whitespace)
167 .map_or("", |space| &value[space..]),
168 };
169 } else if let Some(after) = rest.strip_prefix('/') {
170 rest = after;
171 }
172 }
173 false
174}
175
176pub const SESSION_COOKIE: &str = "__Host-session";
178
179pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
181 response
182 .headers()
183 .get_all(http::header::SET_COOKIE)
184 .iter()
185 .filter_map(|value| value.to_str().ok())
186 .filter_map(|value| value.split(';').next())
187 .filter_map(|pair| pair.split_once('='))
188 .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
189 .collect()
190}
191
192pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
194 response
195 .headers()
196 .get_all(http::header::SET_COOKIE)
197 .iter()
198 .filter_map(|value| value.to_str().ok())
199 .find(|value| value.starts_with(&format!("{name}=")))
200 .map(str::to_string)
201}
202
203pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
205 response_cookies(response)
206 .into_iter()
207 .find(|(name, _)| name == SESSION_COOKIE)
208 .map(|(_, value)| value)
209}
210
211pub fn notification(response: &http::Response<Body>) -> Option<Notification> {
213 notification_in(&response_cookies(response))
214}
215
216pub(crate) fn notification_in(cookies: &[(String, String)]) -> Option<Notification> {
218 let (_, value) = cookies
219 .iter()
220 .find(|(name, _)| name.ends_with("tablo_notification"))?;
221 let json = percent_encoding::percent_decode_str(value)
222 .decode_utf8()
223 .ok()?;
224 serde_json::from_str(&json).ok()
225}
226
227pub fn stale_form(html: &str) -> bool {
230 !dom::tags_where(html, |_, attrs| {
231 attrs.iter().any(|(attr, _)| attr == "data-stale")
232 })
233 .is_empty()
234}