tilo_app/framework/views/
query.rs1use serde::{Deserialize, Serialize};
8
9use super::kind::{FieldDecl, FilterKind, Item, KindDecl, filters};
10
11#[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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
21 pub typed: bool,
22}
23
24impl Chip {
25 #[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 #[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 #[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
70fn 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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub struct QuerySpec {
91 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 #[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 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.text.trim().is_empty() && self.chips.is_empty()
140 }
141
142 #[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 #[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 #[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 #[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 #[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 #[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
255struct Token {
258 raw: String,
259 key: Option<String>,
260 value: String,
261}
262
263fn 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
297fn 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}