#[derive(Debug, Default)]
pub struct Params(Vec<(String, String)>);
impl Params {
pub fn parse(raw: Option<&str>) -> Self {
let pairs = raw
.and_then(|q| serde_urlencoded::from_str::<Vec<(String, String)>>(q).ok())
.unwrap_or_default();
Params(pairs)
}
pub fn get(&self, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn all(&self, key: &str) -> Vec<&str> {
self.0
.iter()
.filter(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
.collect()
}
pub fn csv(&self, key: &str) -> Vec<String> {
self.get(key)
.map(|csv| {
csv.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
}
pub fn since(&self) -> i64 {
self.get("since").and_then(|s| s.parse().ok()).unwrap_or(0)
}
pub fn page(&self) -> (i64, i64) {
let limit = self
.get("limit")
.and_then(|s| s.parse::<i64>().ok())
.filter(|n| *n >= 1)
.map(|n| n.min(MAX_LIMIT))
.unwrap_or(DEFAULT_LIMIT);
let start = self
.get("start")
.and_then(|s| s.parse::<i64>().ok())
.filter(|n| *n >= 0)
.unwrap_or(0);
(limit, start)
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct TypeFilter {
pub include: Vec<String>,
pub exclude: Vec<String>,
}
impl TypeFilter {
fn parse(value: Option<&str>) -> Self {
let mut filter = TypeFilter::default();
let Some(value) = value else {
return filter;
};
for token in value.split("||").map(str::trim).filter(|t| !t.is_empty()) {
if let Some(neg) = token.strip_prefix('-') {
filter.exclude.push(neg.trim().to_string());
} else {
filter.include.push(token.to_string());
}
}
filter
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QMode {
TitleCreatorYear,
Everything,
}
impl QMode {
fn parse(value: Option<&str>) -> Self {
match value {
Some("everything") => QMode::Everything,
_ => QMode::TitleCreatorYear,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sort {
DateModified,
DateAdded,
Title,
Creator,
Date,
ItemType,
}
impl Sort {
fn parse(value: Option<&str>) -> Self {
match value {
Some("dateAdded") => Sort::DateAdded,
Some("title") => Sort::Title,
Some("creator") => Sort::Creator,
Some("date") => Sort::Date,
Some("itemType") => Sort::ItemType,
_ => Sort::DateModified,
}
}
pub fn order_expr(self) -> &'static str {
match self {
Sort::DateModified => "date_modified",
Sort::DateAdded => "date_added",
Sort::Title => "title COLLATE NOCASE",
Sort::Creator => "creator COLLATE NOCASE",
Sort::Date => "date_year",
Sort::ItemType => "item_type",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Asc,
Desc,
}
impl Direction {
fn parse(value: Option<&str>) -> Self {
match value {
Some("asc") => Direction::Asc,
_ => Direction::Desc,
}
}
pub fn sql(self) -> &'static str {
match self {
Direction::Asc => "ASC",
Direction::Desc => "DESC",
}
}
}
const DEFAULT_LIMIT: i64 = 25;
const MAX_LIMIT: i64 = 100;
#[derive(Debug)]
pub struct ItemQuery {
pub q: Option<String>,
pub qmode: QMode,
pub item_type: TypeFilter,
pub tags: Vec<Vec<String>>,
pub sort: Sort,
pub direction: Direction,
pub limit: i64,
pub start: i64,
pub include_trashed: bool,
pub top: bool,
pub only_trashed: bool,
pub collection: Option<String>,
pub parent: Option<String>,
}
impl ItemQuery {
pub fn from_params(params: &Params) -> Self {
let q = params
.get("q")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let tags = params
.all("tag")
.into_iter()
.map(|group| {
group
.split("||")
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from)
.collect::<Vec<_>>()
})
.filter(|group: &Vec<String>| !group.is_empty())
.collect();
let (limit, start) = params.page();
let include_trashed = matches!(params.get("includeTrashed"), Some("1") | Some("true"));
ItemQuery {
q,
qmode: QMode::parse(params.get("qmode")),
item_type: TypeFilter::parse(params.get("itemType")),
tags,
sort: Sort::parse(params.get("sort")),
direction: Direction::parse(params.get("direction")),
limit,
start,
include_trashed,
top: false,
only_trashed: false,
collection: None,
parent: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn query(raw: &str) -> ItemQuery {
ItemQuery::from_params(&Params::parse(Some(raw)))
}
#[test]
fn defaults_when_empty() {
let q = query("");
assert!(q.q.is_none());
assert_eq!(q.qmode, QMode::TitleCreatorYear);
assert_eq!(q.sort, Sort::DateModified);
assert_eq!(q.direction, Direction::Desc);
assert_eq!(q.limit, 25);
assert_eq!(q.start, 0);
assert!(!q.include_trashed);
assert!(q.tags.is_empty());
assert_eq!(q.item_type, TypeFilter::default());
assert!(!q.top);
assert!(!q.only_trashed);
assert!(q.collection.is_none());
assert!(q.parent.is_none());
}
#[test]
fn blank_q_is_dropped() {
assert!(query("q=").q.is_none());
assert!(query("q=%20%20").q.is_none());
assert_eq!(query("q=rust").q.as_deref(), Some("rust"));
}
#[test]
fn qmode_everything_opt_in() {
assert_eq!(query("qmode=everything").qmode, QMode::Everything);
assert_eq!(
query("qmode=titleCreatorYear").qmode,
QMode::TitleCreatorYear
);
assert_eq!(query("qmode=bogus").qmode, QMode::TitleCreatorYear);
}
#[test]
fn item_type_includes_and_excludes() {
let f = query("itemType=book || journalArticle").item_type;
assert_eq!(f.include, vec!["book", "journalArticle"]);
assert!(f.exclude.is_empty());
let f = query("itemType=-note || -attachment").item_type;
assert!(f.include.is_empty());
assert_eq!(f.exclude, vec!["note", "attachment"]);
}
#[test]
fn tags_repeat_for_and_pipe_for_or() {
let q = query("tag=urgent&tag=todo || later");
assert_eq!(q.tags, vec![vec!["urgent"], vec!["todo", "later"]]);
}
#[test]
fn limit_is_clamped_and_start_floored() {
assert_eq!(query("limit=500").limit, 100);
assert_eq!(query("limit=0").limit, 25);
assert_eq!(query("limit=-3").limit, 25);
assert_eq!(query("limit=10").limit, 10);
assert_eq!(query("start=-5").start, 0);
assert_eq!(query("start=40").start, 40);
}
#[test]
fn include_trashed_opt_in() {
assert!(query("includeTrashed=1").include_trashed);
assert!(query("includeTrashed=true").include_trashed);
assert!(!query("includeTrashed=0").include_trashed);
}
#[test]
fn sort_and_direction_whitelist() {
assert_eq!(query("sort=title").sort, Sort::Title);
assert_eq!(query("sort=nonsense").sort, Sort::DateModified);
assert_eq!(query("direction=asc").direction, Direction::Asc);
assert_eq!(query("direction=nonsense").direction, Direction::Desc);
assert_eq!(Sort::Date.order_expr(), "date_year");
}
#[test]
fn csv_and_since() {
let p = Params::parse(Some("itemKey=AAAAAAAA,%20BBBBBBBB,&since=7"));
assert_eq!(p.csv("itemKey"), vec!["AAAAAAAA", "BBBBBBBB"]);
assert_eq!(p.since(), 7);
assert_eq!(Params::parse(None).since(), 0);
}
}