initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
raf 2026-01-30 22:05:46 +03:00
commit 6a73d11c4b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
124 changed files with 34856 additions and 0 deletions

View file

@ -0,0 +1,256 @@
use serde::{Deserialize, Serialize};
use winnow::combinator::{alt, delimited, preceded, repeat};
use winnow::token::{take_till, take_while};
use winnow::{ModalResult, Parser};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SearchQuery {
FullText(String),
FieldMatch { field: String, value: String },
And(Vec<SearchQuery>),
Or(Vec<SearchQuery>),
Not(Box<SearchQuery>),
Prefix(String),
Fuzzy(String),
TypeFilter(String),
TagFilter(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchRequest {
pub query: SearchQuery,
pub sort: SortOrder,
pub pagination: crate::model::Pagination,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub items: Vec<crate::model::MediaItem>,
pub total_count: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SortOrder {
#[default]
Relevance,
DateAsc,
DateDesc,
NameAsc,
NameDesc,
SizeAsc,
SizeDesc,
}
fn ws<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
take_while(0.., ' ').parse_next(input)
}
fn quoted_string(input: &mut &str) -> ModalResult<String> {
delimited('"', take_till(0.., '"'), '"')
.map(|s: &str| s.to_string())
.parse_next(input)
}
fn bare_word(input: &mut &str) -> ModalResult<String> {
take_while(1.., |c: char| !c.is_whitespace() && c != ')' && c != '(')
.map(|s: &str| s.to_string())
.parse_next(input)
}
fn word_or_quoted(input: &mut &str) -> ModalResult<String> {
alt((quoted_string, bare_word)).parse_next(input)
}
fn not_expr(input: &mut &str) -> ModalResult<SearchQuery> {
preceded(('-', ws), atom)
.map(|q| SearchQuery::Not(Box::new(q)))
.parse_next(input)
}
fn field_match(input: &mut &str) -> ModalResult<SearchQuery> {
let field_name =
take_while(1.., |c: char| c.is_alphanumeric() || c == '_').map(|s: &str| s.to_string());
(field_name, ':', word_or_quoted)
.map(|(field, _, value)| match field.as_str() {
"type" => SearchQuery::TypeFilter(value),
"tag" => SearchQuery::TagFilter(value),
_ => SearchQuery::FieldMatch { field, value },
})
.parse_next(input)
}
fn prefix_expr(input: &mut &str) -> ModalResult<SearchQuery> {
let word = take_while(1.., |c: char| {
!c.is_whitespace() && c != ')' && c != '(' && c != '*'
})
.map(|s: &str| s.to_string());
(word, '*')
.map(|(w, _)| SearchQuery::Prefix(w))
.parse_next(input)
}
fn fuzzy_expr(input: &mut &str) -> ModalResult<SearchQuery> {
let word = take_while(1.., |c: char| {
!c.is_whitespace() && c != ')' && c != '(' && c != '~'
})
.map(|s: &str| s.to_string());
(word, '~')
.map(|(w, _)| SearchQuery::Fuzzy(w))
.parse_next(input)
}
fn paren_expr(input: &mut &str) -> ModalResult<SearchQuery> {
delimited(('(', ws), or_expr, (ws, ')')).parse_next(input)
}
fn not_or_keyword(input: &mut &str) -> ModalResult<()> {
if let Some(rest) = input.strip_prefix("OR")
&& (rest.is_empty() || rest.starts_with(' ') || rest.starts_with(')'))
{
return Err(winnow::error::ErrMode::Backtrack(
winnow::error::ContextError::new(),
));
}
Ok(())
}
fn full_text(input: &mut &str) -> ModalResult<SearchQuery> {
not_or_keyword.parse_next(input)?;
word_or_quoted.map(SearchQuery::FullText).parse_next(input)
}
fn atom(input: &mut &str) -> ModalResult<SearchQuery> {
alt((
paren_expr,
not_expr,
field_match,
prefix_expr,
fuzzy_expr,
full_text,
))
.parse_next(input)
}
fn and_expr(input: &mut &str) -> ModalResult<SearchQuery> {
let first = atom.parse_next(input)?;
let rest: Vec<SearchQuery> = repeat(0.., preceded(ws, atom)).parse_next(input)?;
if rest.is_empty() {
Ok(first)
} else {
let mut terms = vec![first];
terms.extend(rest);
Ok(SearchQuery::And(terms))
}
}
fn or_expr(input: &mut &str) -> ModalResult<SearchQuery> {
let first = and_expr.parse_next(input)?;
let rest: Vec<SearchQuery> =
repeat(0.., preceded((ws, "OR", ws), and_expr)).parse_next(input)?;
if rest.is_empty() {
Ok(first)
} else {
let mut terms = vec![first];
terms.extend(rest);
Ok(SearchQuery::Or(terms))
}
}
pub fn parse_search_query(input: &str) -> crate::error::Result<SearchQuery> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(SearchQuery::FullText(String::new()));
}
let mut input = trimmed;
or_expr
.parse_next(&mut input)
.map_err(|e| crate::error::PinakesError::SearchParse(format!("{e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_text() {
let q = parse_search_query("hello").unwrap();
assert_eq!(q, SearchQuery::FullText("hello".into()));
}
#[test]
fn test_field_match() {
let q = parse_search_query("artist:Beatles").unwrap();
assert_eq!(
q,
SearchQuery::FieldMatch {
field: "artist".into(),
value: "Beatles".into()
}
);
}
#[test]
fn test_type_filter() {
let q = parse_search_query("type:pdf").unwrap();
assert_eq!(q, SearchQuery::TypeFilter("pdf".into()));
}
#[test]
fn test_tag_filter() {
let q = parse_search_query("tag:music").unwrap();
assert_eq!(q, SearchQuery::TagFilter("music".into()));
}
#[test]
fn test_and_implicit() {
let q = parse_search_query("hello world").unwrap();
assert_eq!(
q,
SearchQuery::And(vec![
SearchQuery::FullText("hello".into()),
SearchQuery::FullText("world".into()),
])
);
}
#[test]
fn test_or() {
let q = parse_search_query("hello OR world").unwrap();
assert_eq!(
q,
SearchQuery::Or(vec![
SearchQuery::FullText("hello".into()),
SearchQuery::FullText("world".into()),
])
);
}
#[test]
fn test_not() {
let q = parse_search_query("-excluded").unwrap();
assert_eq!(
q,
SearchQuery::Not(Box::new(SearchQuery::FullText("excluded".into())))
);
}
#[test]
fn test_prefix() {
let q = parse_search_query("hel*").unwrap();
assert_eq!(q, SearchQuery::Prefix("hel".into()));
}
#[test]
fn test_fuzzy() {
let q = parse_search_query("hello~").unwrap();
assert_eq!(q, SearchQuery::Fuzzy("hello".into()));
}
#[test]
fn test_quoted() {
let q = parse_search_query("\"hello world\"").unwrap();
assert_eq!(q, SearchQuery::FullText("hello world".into()));
}
}