Skip to main content

tilo_app/framework/views/
query.rs

1//! What a query view asks: the typed text and the chips that narrow it.
2//!
3//! The chips come from the types' filterable fields. A person picks them
4//! under the search field, or types them as tokens (`artist:ada`,
5//! `liked:yes`); both are the same [`Chip`]s.
6
7use serde::{Deserialize, Serialize};
8
9use super::kind::{FieldDecl, FilterKind, Item, KindDecl, filters};
10
11/// One chip: the filterable field it narrows by, and the value for a value
12/// chip (none for a toggle that is on).
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Chip {
15    pub key: String,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub value: Option<String>,
18    /// Typed into the field as a token rather than picked: the token in the
19    /// field is what takes it away again.
20    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
21    pub typed: bool,
22}
23
24impl Chip {
25    /// The value chip `key: value`.
26    #[must_use]
27    pub fn value(key: impl Into<String>, value: impl Into<String>) -> Self {
28        Self {
29            key: key.into(),
30            value: Some(value.into()),
31            typed: false,
32        }
33    }
34
35    #[must_use]
36    pub fn toggle(key: impl Into<String>) -> Self {
37        Self {
38            key: key.into(),
39            value: None,
40            typed: false,
41        }
42    }
43
44    /// Whether `item` passes this chip. An item of a type without the field
45    /// does not pass: narrowing by Artist leaves the artists' things.
46    #[must_use]
47    pub fn admits(&self, item: &Item) -> bool {
48        match &self.value {
49            None => item.is_on(&self.key),
50            Some(wanted) => {
51                let wanted = fold(wanted);
52                item.values(&self.key)
53                    .any(|value| value_matches(&fold(value), &wanted))
54            }
55        }
56    }
57
58    /// The chip as a person reads it, given its field: "Artist: Ada",
59    /// "Liked".
60    #[must_use]
61    pub fn label(&self, field: Option<&FieldDecl>) -> String {
62        let name = field.map_or(self.key.as_str(), |field| field.label.as_str());
63        match &self.value {
64            Some(value) => format!("{name}: {value}"),
65            None => name.to_owned(),
66        }
67    }
68}
69
70/// A picked or typed value matches a field's value at the start of one of
71/// its words: `ada` finds "Ada Lovelace" and "The Ada Trio", not "Canada".
72fn value_matches(value: &str, wanted: &str) -> bool {
73    if wanted.is_empty() {
74        return true;
75    }
76    value.starts_with(wanted)
77        || value.match_indices(wanted).any(|(at, _)| {
78            value
79                .get(..at)
80                .is_some_and(|before| before.ends_with(|c: char| !c.is_alphanumeric()))
81        })
82}
83
84fn fold(text: &str) -> String {
85    tilo_search::fold(text.trim())
86}
87
88/// What a query view asks for.
89#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub struct QuerySpec {
91    /// The words typed, tokens taken out.
92    pub text: String,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub chips: Vec<Chip>,
95}
96
97impl QuerySpec {
98    #[must_use]
99    pub fn text(text: impl Into<String>) -> Self {
100        Self {
101            text: text.into(),
102            chips: Vec::new(),
103        }
104    }
105
106    /// The query `input` asks, as typed into the field, with the chips
107    /// picked beside it: a token naming a filterable field of `kinds`
108    /// (`artist:ada`, `artist:"ada lovelace"`, `liked:yes`) becomes a typed
109    /// chip, everything else is text. A token naming no such field, or one
110    /// with no value yet (`artist:`), stays text.
111    #[must_use]
112    pub fn parse(input: &str, picked: &[Chip], kinds: &[KindDecl]) -> Self {
113        let filters = filters(kinds);
114        let mut text = Vec::new();
115        let mut chips: Vec<Chip> = picked.iter().filter(|chip| !chip.typed).cloned().collect();
116        for token in tokens(input) {
117            match typed_chip(&token, &filters) {
118                Some(chip) => {
119                    if !chips.contains(&Chip {
120                        typed: false,
121                        ..chip.clone()
122                    }) && !chips.contains(&chip)
123                    {
124                        chips.push(chip);
125                    }
126                }
127                None => text.push(token.raw),
128            }
129        }
130        Self {
131            text: text.join(" "),
132            chips,
133        }
134    }
135
136    /// Nothing is asked: no text and no chip.
137    #[must_use]
138    pub fn is_empty(&self) -> bool {
139        self.text.trim().is_empty() && self.chips.is_empty()
140    }
141
142    /// Whether `item` passes the chips: one of each filter's chosen values
143    /// (Artist: Ada or Grace), and every filter.
144    #[must_use]
145    pub fn admits(&self, item: &Item) -> bool {
146        self.chips.iter().all(|chip| {
147            self.chips
148                .iter()
149                .filter(|other| other.key == chip.key)
150                .any(|other| other.admits(item))
151        })
152    }
153
154    /// The query with `chip` chosen too, or taken away when it is chosen. A
155    /// filter may hold several values; a toggle is on or off.
156    #[must_use]
157    pub fn toggled(&self, chip: &Chip) -> Self {
158        let mut next = self.clone();
159        let same = |held: &Chip| held.key == chip.key && held.value == chip.value;
160        if let Some(at) = next.chips.iter().position(same) {
161            next.chips.remove(at);
162        } else {
163            next.chips.push(Chip {
164                typed: false,
165                ..chip.clone()
166            });
167        }
168        next
169    }
170
171    /// The query without the chosen values of the filter `key`; one typed as
172    /// a token stays, the token in the field takes it away.
173    #[must_use]
174    pub fn cleared(&self, key: &str) -> Self {
175        let mut next = self.clone();
176        next.chips.retain(|chip| chip.key != key || chip.typed);
177        next
178    }
179
180    /// What two equal queries share, however they were typed: the folded
181    /// words and the chips in a fixed order. Caches are keyed by it.
182    #[must_use]
183    pub fn key(&self) -> String {
184        let mut chips: Vec<String> = self
185            .chips
186            .iter()
187            .map(|chip| {
188                format!(
189                    "{}={}",
190                    chip.key,
191                    chip.value.as_deref().map(fold).unwrap_or_default()
192                )
193            })
194            .collect();
195        chips.sort();
196        format!(
197            "{}\u{1f}{}",
198            words(&self.text).join(" "),
199            chips.join("\u{1f}")
200        )
201    }
202
203    /// Whether this query narrows `earlier`: the same chips, and text that
204    /// goes on from its text. What `earlier` found that still matches is
205    /// what this one finds first.
206    #[must_use]
207    pub fn refines(&self, earlier: &Self) -> bool {
208        let (mine, theirs) = (fold(&self.text), fold(&earlier.text));
209        !theirs.is_empty()
210            && mine.len() > theirs.len()
211            && mine.starts_with(&theirs)
212            && same_chips(&self.chips, &earlier.chips)
213    }
214
215    /// Whether `item` matches the text of this query by its searchable
216    /// fields: every word occurs at a word start in one of them. What a
217    /// refined query keeps of an earlier answer before its own arrives.
218    #[must_use]
219    pub fn text_admits(&self, item: &Item, kind: Option<&KindDecl>) -> bool {
220        let wanted = words(&self.text);
221        if wanted.is_empty() {
222            return true;
223        }
224        let fields: Vec<String> = item
225            .fields
226            .iter()
227            .filter(|(key, _)| {
228                kind.and_then(|kind| kind.field_of(key))
229                    .is_none_or(|field| field.searchable)
230            })
231            .map(|(_, value)| fold(value))
232            .collect();
233        wanted
234            .iter()
235            .all(|word| fields.iter().any(|field| value_matches(field, word)))
236    }
237}
238
239fn same_chips(a: &[Chip], b: &[Chip]) -> bool {
240    let strip = |chips: &[Chip]| {
241        let mut out: Vec<(String, Option<String>)> = chips
242            .iter()
243            .map(|chip| (chip.key.clone(), chip.value.as_deref().map(fold)))
244            .collect();
245        out.sort();
246        out
247    };
248    strip(a) == strip(b)
249}
250
251fn words(text: &str) -> Vec<String> {
252    fold(text).split_whitespace().map(str::to_owned).collect()
253}
254
255/// One whitespace-separated token of the input, with its key and value when
256/// it has the `key:value` shape.
257struct Token {
258    raw: String,
259    key: Option<String>,
260    value: String,
261}
262
263/// The input's tokens. A value in double quotes may hold spaces
264/// (`artist:"ada lovelace"`).
265fn tokens(input: &str) -> Vec<Token> {
266    let mut out = Vec::new();
267    let mut chars = input.char_indices().peekable();
268    while let Some(&(start, c)) = chars.peek() {
269        if c.is_whitespace() {
270            chars.next();
271            continue;
272        }
273        let mut end = start;
274        let mut quoted = false;
275        while let Some(&(at, c)) = chars.peek() {
276            if c == '"' {
277                quoted = !quoted;
278            } else if c.is_whitespace() && !quoted {
279                break;
280            }
281            end = at.saturating_add(c.len_utf8());
282            chars.next();
283        }
284        let raw = input.get(start..end).unwrap_or_default().to_owned();
285        let (key, value) = match raw.split_once(':') {
286            Some((key, value)) if !key.is_empty() && !key.contains('"') => (
287                Some(key.to_owned()),
288                value.trim_matches('"').trim().to_owned(),
289            ),
290            _ => (None, String::new()),
291        };
292        out.push(Token { raw, key, value });
293    }
294    out
295}
296
297/// The chip a token names, when its key is a filterable field (by key or by
298/// its label) and it has a value.
299fn typed_chip(token: &Token, filters: &[&FieldDecl]) -> Option<Chip> {
300    let key = fold(token.key.as_deref()?);
301    let field = filters
302        .iter()
303        .find(|field| fold(field.key) == key || fold(&field.label) == key)?;
304    if token.value.is_empty() {
305        return None;
306    }
307    match field.filter? {
308        FilterKind::Value => Some(Chip {
309            key: field.key.to_owned(),
310            value: Some(token.value.clone()),
311            typed: true,
312        }),
313        FilterKind::Toggle => {
314            let on = matches!(
315                fold(&token.value).as_str(),
316                "yes" | "true" | "on" | "1" | "y"
317            );
318            on.then(|| Chip {
319                key: field.key.to_owned(),
320                value: None,
321                typed: true,
322            })
323        }
324    }
325}