use std::{borrow::Cow, collections::BTreeMap};
use topcoat::context::Cx;
use super::filter::FilterValue;
use crate::{schema::clamp_query_term, topcoat_compat::href};
pub(crate) fn bulk_token(key: &str) -> String {
format!(",{},", href::encode_query_value(key))
}
pub(crate) fn bulk_key(body: &str) -> String {
href::decode_query_value(body)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Sort {
pub column: String,
pub descending: bool,
}
impl Sort {
pub fn asc(column: impl Into<String>) -> Self {
Self {
column: column.into(),
descending: false,
}
}
pub fn desc(column: impl Into<String>) -> Self {
Self {
column: column.into(),
descending: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cursor {
After(String),
Before(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TableState {
pub prefix: Option<String>,
pub search: Option<String>,
pub sort: Option<Sort>,
pub cursor: Option<Cursor>,
pub page: Option<u32>,
pub per_page: Option<u32>,
pub filters: BTreeMap<String, FilterValue>,
pub filters_dropped: bool,
pub group_by: Option<String>,
pub tab: Option<String>,
}
#[derive(Default)]
struct UrlProjection<'a> {
tab: Option<&'a str>,
search: Option<&'a str>,
sort: Option<(&'a str, &'a str)>,
filters: bool,
group_by: Option<&'a str>,
cursor: Option<&'a Cursor>,
page: Option<u32>,
per_page: Option<u32>,
}
pub(crate) const MAX_FILTERS: usize = 32;
pub(crate) const MAX_FILTER_VALUES: usize = 64;
pub(crate) const MAX_FILTER_LEN: usize = 256;
const FILTER_PREFIX: &str = "f.";
impl TableState {
pub fn from_cx(cx: &Cx) -> Self {
Self::from_query(&request_query(cx))
}
pub fn from_query_prefixed(query: &str, prefix: &str) -> Self {
let dotted = format!("{prefix}.");
let own = form_urlencoded::parse(query.as_bytes()).filter_map(|(name, value)| {
name.strip_prefix(dotted.as_str())
.map(|name| (Cow::Owned(name.to_string()), value))
});
Self {
prefix: Some(prefix.to_string()),
..Self::from_pairs(own)
}
}
pub(crate) fn param(&self, name: &str) -> String {
match &self.prefix {
Some(prefix) => format!("{prefix}.{name}"),
None => name.to_string(),
}
}
pub(crate) fn filter_param(&self, name: &str) -> String {
self.param(&format!("{FILTER_PREFIX}{name}"))
}
fn filter_pairs<'a>(
&'a self,
name: &'a str,
value: &'a FilterValue,
) -> impl Iterator<Item = (String, &'a str)> + 'a {
let param = self.filter_param(name);
let values = value
.values()
.iter()
.map(move |held| (param.clone(), held.as_str()));
let parts = value
.parts()
.map(move |(part, held)| (self.filter_param(&format!("{name}.{part}")), held));
values.chain(parts)
}
pub(crate) fn spell_tab(&self, key: &str) -> String {
href::encode_query(&[(self.param("tab"), key)])
}
pub(crate) fn spell_filter(&self, name: &str, value: &FilterValue) -> String {
href::encode_query(&self.filter_pairs(name, value).collect::<Vec<_>>())
}
pub fn from_query(query: &str) -> Self {
Self::from_pairs(form_urlencoded::parse(query.as_bytes()))
}
fn from_pairs<'q>(pairs: impl Iterator<Item = (Cow<'q, str>, Cow<'q, str>)>) -> Self {
let mut state = Self::default();
let (mut sort, mut dir, mut after, mut before) = (None, None, None, None);
let mut seen = [false; 9];
for (key, value) in pairs {
if let Some(filter) = key.strip_prefix(FILTER_PREFIX) {
state.add_filter(filter, value.trim());
continue;
}
let slot = match key.as_ref() {
"q" => 0,
"sort" => 1,
"dir" => 2,
"after" => 3,
"before" => 4,
"group_by" => 5,
"page" => 6,
"per_page" => 7,
"tab" => 8,
"filters" => {
state.filters_dropped |= !value.trim().is_empty();
continue;
}
_ => continue,
};
if std::mem::replace(&mut seen[slot], true) {
continue;
}
let non_empty = || Some(value.trim().to_string()).filter(|v| !v.is_empty());
let positive = || value.trim().parse::<u32>().ok().filter(|n| *n > 0);
match slot {
0 => state.search = Some(clamp_query_term(&value)).filter(|t| !t.is_empty()),
1 => sort = non_empty(),
2 => dir = Some(value.trim() == "desc"),
3 => after = non_empty(),
4 => before = non_empty(),
5 => state.group_by = non_empty(),
6 => state.page = positive(),
7 => state.per_page = positive(),
_ => state.tab = non_empty(),
}
}
state.sort = sort.map(|column| Sort {
column,
descending: dir.unwrap_or(false),
});
state.cursor = match (after, before) {
(Some(token), None) => Some(Cursor::After(token)),
(None, Some(token)) => Some(Cursor::Before(token)),
_ => None,
};
state
}
fn add_filter(&mut self, filter: &str, value: &str) {
let (name, part) = match filter.split_once('.') {
Some((name, part)) => (name, Some(part)),
None => (filter, None),
};
let held = self.filters.get(name);
if name.is_empty()
|| part == Some("")
|| value.is_empty()
|| held.is_some_and(|held| held.holds(part, value))
{
return;
}
let room = match held {
Some(held) => held.len() < MAX_FILTER_VALUES,
None => self.filters.len() < MAX_FILTERS,
};
if !room
|| [name, part.unwrap_or_default(), value]
.iter()
.any(|s| s.len() > MAX_FILTER_LEN)
{
self.filters_dropped = true;
return;
}
let held = self.filters.entry(name.to_string()).or_default();
match part {
Some(part) => held.insert_part(part, value),
None => held.push(value),
}
}
pub(crate) fn query(&self) -> String {
self.project(self.full())
}
pub(crate) fn list_url(&self, path: &str) -> String {
href::with_query(path, &self.query())
}
pub(crate) fn without_search(&self, path: &str) -> String {
let projection = UrlProjection {
search: None,
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn without_filters(&self, path: &str) -> String {
let projection = UrlProjection {
filters: false,
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn without_narrowing(&self, path: &str) -> String {
let projection = UrlProjection {
filters: false,
tab: None,
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn with_tab(&self, path: &str, key: Option<&str>) -> String {
let projection = UrlProjection {
tab: key,
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn first_page(&self, path: &str) -> String {
href::with_query(path, &self.project(self.first_page_projection()))
}
pub(crate) fn without_cursor(&self, path: &str) -> String {
let projection = UrlProjection {
cursor: None,
..self.full()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn with_cursor(&self, path: &str, cursor: &Cursor) -> String {
let projection = UrlProjection {
cursor: Some(cursor),
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn with_page(&self, path: &str, page: u32) -> String {
let projection = UrlProjection {
page: Some(page).filter(|page| *page > 1),
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn sorted_by(&self, path: &str, column: &str, descending: bool) -> String {
let projection = UrlProjection {
sort: Some((column, if descending { "desc" } else { "asc" })),
..self.first_page_projection()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn hidden_pairs(&self, skip: &str) -> Vec<(String, String)> {
let skip = self.param(skip);
form_urlencoded::parse(self.project(self.first_page_projection()).as_bytes())
.filter(|(name, _)| *name != skip)
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect()
}
fn sort_pair(&self) -> Option<(&str, &str)> {
self.sort
.as_ref()
.map(|s| (s.column.as_str(), if s.descending { "desc" } else { "asc" }))
}
fn full(&self) -> UrlProjection<'_> {
UrlProjection {
tab: self.tab.as_deref(),
search: self.search.as_deref(),
sort: self.sort_pair(),
filters: true,
group_by: self.group_by.as_deref(),
cursor: self.cursor.as_ref(),
page: self.page,
per_page: self.per_page,
}
}
fn first_page_projection(&self) -> UrlProjection<'_> {
UrlProjection {
cursor: None,
page: None,
..self.full()
}
}
fn project(&self, projection: UrlProjection<'_>) -> String {
let TableState {
prefix: _,
search: _,
sort: _,
cursor: _,
page: _,
per_page: _,
filters: _,
filters_dropped: _,
group_by: _,
tab: _,
} = self;
let filters = projection
.filters
.then_some(&self.filters)
.into_iter()
.flatten()
.flat_map(|(name, value)| self.filter_pairs(name, value))
.map(|(key, value)| (key, Some(value)));
let cursor = match projection.cursor {
Some(Cursor::After(token)) => ("after", Some(token.as_str())),
Some(Cursor::Before(token)) => ("before", Some(token.as_str())),
None => ("after", None),
};
let per_page = projection.per_page.map(|n| n.to_string());
let page = projection.page.map(|n| n.to_string());
let pairs: Vec<(String, &str)> = [
(self.param("tab"), projection.tab),
(self.param("q"), projection.search),
(
self.param("sort"),
projection.sort.map(|(column, _)| column),
),
(self.param("dir"), projection.sort.map(|(_, dir)| dir)),
]
.into_iter()
.chain(filters)
.chain([
(self.param("group_by"), projection.group_by),
(self.param("per_page"), per_page.as_deref()),
(self.param("page"), page.as_deref()),
(self.param(cursor.0), cursor.1),
])
.filter_map(|(key, value)| value.map(|value| (key, value)))
.collect();
href::encode_query(&pairs)
}
}
fn fnv1a_32(s: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for byte in s.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
pub(crate) fn row_dom_id(scope: &str, key: &str) -> String {
dom_id(&format!("row-{scope}"), key)
}
pub(crate) fn group_header_dom_id(scope: &str, label: &str) -> String {
dom_id(&format!("group-{scope}"), label)
}
fn dom_id(prefix: &str, key: &str) -> String {
let mut out = String::with_capacity(prefix.len() + key.len() + 14);
out.push_str(prefix);
out.push('-');
for c in key.chars() {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.') {
out.push(c);
} else {
out.push('-');
}
}
out.push_str(&format!("-{:08x}", fnv1a_32(key)));
out
}
pub(crate) fn request_query(cx: &Cx) -> String {
topcoat::context::try_context::<http::request::Parts>(cx)
.and_then(|parts| parts.uri.query().map(str::to_string))
.unwrap_or_default()
}
pub(crate) fn query_of(url: &str) -> &str {
url.split_once('?').map_or("", |(_, query)| query)
}
#[cfg(test)]
mod tests;