Skip to main content

max / goingson

9.5 KB · 272 lines History Blame Raw
1 //! The search screen, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! The repository, the FTS index (`migrations/sqlite/011_full_text_search.sql`),
6 //! the colon grammar (`goingson_core::search_parser`) and the command are what
7 //! this draws.
8 //!
9 //! # A synchronous handler serves it
10 //!
11 //! [`crate::commands::run_search`] is synchronous: the repository trait's
12 //! `search` is a plain function. So this needs none of the offload machinery a
13 //! long-running write needs; it calls the command's own body and answers with
14 //! the result.
15 //!
16 //! # The shape
17 //!
18 //! - `GET /search` — the document: the query box, and whatever the current
19 //! query finds.
20 //! - `GET /search/results` — the filters and the result list alone, which is
21 //! what the box asks for as it is typed.
22 //!
23 //! The box is a [`Field`] with [`Field::consults`]: a field that asks a route
24 //! about its value once the value has stood still, with the answer landing
25 //! where [`Action::replacing`] points. The query is also a query param on
26 //! `/search`, so a search has an address and is not a state the field holds
27 //! privately.
28 //!
29 //! # The parsed filters are shown back
30 //!
31 //! `commands::search` collects a label per filter it understood, and drawing
32 //! them is what makes a colon grammar learnable rather than secret: typing
33 //! `is:overdue` and seeing `is:overdue` come back is the parser saying it
34 //! agreed. A filter the parser did not recognise stays in the free text and
35 //! does not appear, which is the same answer said by omission.
36 //!
37 //! # A row says which kind it is
38 //!
39 //! Results are heterogeneous across all five `type:` values, so a row carries a
40 //! [`Tag`] naming its kind, the way status is carried elsewhere. Each row
41 //! activates to that kind's own screen; every one of the five has a described
42 //! detail route, which is why no kind is left inert.
43
44 // Handlers take their request by value because `quasi_router::Handler` is a
45 // plain `fn(&S, Request)` pointer, so the signature is the router's.
46 #![allow(clippy::needless_pass_by_value)]
47
48 use quasi_declare::declare;
49 use quasi_router::screen::Tag;
50 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
51
52 use crate::commands::{SearchInput, SearchResultResponse, SearchResultsResponse};
53 use crate::state::AppState;
54
55 #[cfg(test)]
56 mod tests;
57
58 /// The region the query box replaces as it is typed.
59 const RESULTS: &str = "search-results";
60
61 /// The kinds a result can be, and the routes they open.
62 ///
63 /// Spelled here rather than inferred from the string, because a row that
64 /// activated a path built by interpolating an unrecognised kind is a 404 the
65 /// first time somebody presses it.
66 fn destination(result: &SearchResultResponse) -> Option<Action> {
67 let path = match result.result_type.as_str() {
68 "task" => format!("/tasks/{}", result.id),
69 "project" => format!("/projects/{}", result.id),
70 "event" => format!("/events/{}", result.id),
71 "contact" => format!("/contacts/{}", result.id),
72 "email" => format!("/emails/{}", result.id),
73 _ => return None,
74 };
75 Some(Action::get(path))
76 }
77
78 /// What a row's tag calls its kind.
79 fn kind_label(result_type: &str) -> &str {
80 match result_type {
81 "task" => "Task",
82 "project" => "Project",
83 "event" => "Event",
84 "contact" => "Contact",
85 "email" => "Email",
86 other => other,
87 }
88 }
89
90 /// Whether a result carries the FTS extract around its match.
91 fn has_snippet(result: &SearchResultResponse) -> bool {
92 !snippet(result).is_empty()
93 }
94
95 /// That extract, or nothing. R9: read whether or not it is placed.
96 fn snippet(result: &SearchResultResponse) -> &str {
97 result.snippet.as_deref().unwrap_or_default()
98 }
99
100 /// Whether the result names a project.
101 fn has_project(result: &SearchResultResponse) -> bool {
102 !project(result).is_empty()
103 }
104
105 /// That project, or nothing.
106 fn project(result: &SearchResultResponse) -> &str {
107 result.project_name.as_deref().unwrap_or_default()
108 }
109
110 declare! {
111 /// One result as a row.
112 ///
113 /// The snippet is the FTS extract around the match, which is the sentence
114 /// that says why this row is here, so it is the row's secondary text. The
115 /// project is a plain fact and goes in `meta`.
116 ///
117 /// A kind with no route opens nothing rather than opening a path built by
118 /// interpolating a string nobody recognised. An `Option` is an iterator of
119 /// at most one, and `.into_iter()` is the method step that says so.
120 shape row_for(result: &SearchResultResponse) -> Row;
121
122 row &result.title {
123 secondary snippet(result) when has_snippet(result);
124 meta project(result) when has_project(result);
125 token Tag::badge(kind_label(&result.result_type));
126 for opens in destination(result).into_iter() {
127 activate to doing opens;
128 }
129 }
130 }
131
132 /// A param that is present and not blank. Blank is absent, which is what an
133 /// emptied query box means.
134 fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> {
135 params.get(name).map(str::trim).filter(|v| !v.is_empty())
136 }
137
138 /// Run the search behind the command, from state a route handler holds.
139 fn look(state: &AppState, query: &str) -> Result<SearchResultsResponse, RouteError> {
140 crate::commands::run_search(
141 state,
142 SearchInput {
143 query: query.to_owned(),
144 result_type: None,
145 project_id: None,
146 date_from: None,
147 date_to: None,
148 limit: None,
149 offset: None,
150 },
151 )
152 .map_err(|error| RouteError::internal(error.to_string()))
153 }
154
155 declare! {
156 /// The grammar, said once on an empty screen.
157 ///
158 /// Not a permanent panel: it is what there is to show when there is no
159 /// query and no results, and it is the only place the vocabulary is written
160 /// down for somebody who has not read the parser.
161 shape grammar() -> Vec<Node>;
162
163 text "Type to search across tasks, emails, events, projects and contacts. \
164 Words narrow by text; a colon filter narrows by fact.";
165 text "is:overdue is:today is:tomorrow is:thisweek is:snoozed";
166 text "is:pending is:started is:completed is:waiting";
167 text "priority:high priority:medium priority:low";
168 text "type:task type:email type:event type:project type:contact";
169 text "in:ProjectName tag:name -tag:name";
170 text "after:2026-08-01 before:2026-09-01";
171 }
172
173 /// How many results, in words.
174 fn tally(found: &SearchResultsResponse) -> String {
175 match found.total {
176 1 => "1 result".to_owned(),
177 total => format!("{total} results"),
178 }
179 }
180
181 /// Whether anything was asked at all.
182 ///
183 /// The read happens in the handler and the answer arrives here, so this shape
184 /// is infallible: a screen that could fail would have to say what it draws when
185 /// it does, and the route already answers that.
186 fn asked(found: Option<&SearchResultsResponse>) -> bool {
187 found.is_some()
188 }
189
190 declare! {
191 /// The filters and the results, which is what the query box replaces.
192 shape results(found: Option<&SearchResultsResponse>) -> Slot;
193
194 region RESULTS as Pane {
195 for node in grammar() {
196 include node unless asked(found);
197 }
198
199 for hit in found.into_iter() {
200 // What the parser understood, said back. A badge rather than a
201 // chip: these are the query's own words read aloud, and pressing
202 // one would have to mean removing it from a string the reader is
203 // holding the caret in.
204 for filter in hit.active_filters.iter() {
205 badge filter;
206 }
207
208 empty "Nothing matches that." when hit.results.is_empty();
209
210 text tally(hit) unless hit.results.is_empty();
211 list {
212 for result in hit.results.iter() {
213 include row_for(result);
214 }
215 } unless hit.results.is_empty();
216 }
217 }
218 }
219
220 /// What is already in the box, or nothing.
221 fn typed(query: Option<&str>) -> &str {
222 query.unwrap_or_default()
223 }
224
225 declare! {
226 /// The query box.
227 ///
228 /// The answer replaces the results region, and the field asks for it once
229 /// the value has stood still. No submit: a search is a read, and waiting
230 /// for one would be the box pretending it writes something.
231 shape box_for(query: Option<&str>) -> Field;
232
233 field Text "q" "Search" {
234 consults Action::get("/search/results").replacing(RESULTS);
235 placeholder "invoice is:overdue in:Ledger";
236 value typed(query);
237 }
238 }
239
240 /// The whole screen.
241 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
242 let query = text(&request.carried, "q");
243
244 let band = Slot::new("search-band", RegionKind::Band)
245 .with(Node::page("Search"))
246 .with(Node::Field(Box::new(box_for(query))));
247
248 let found = query.map(|query| look(state, query)).transpose()?;
249
250 Ok(Screen::list_detail("Search", false)
251 .at_place(super::shell::SEARCH)
252 .with(band)
253 .with(results(found.as_ref()))
254 .into())
255 }
256
257 /// The results alone, which is what the query box asks for as it is typed.
258 fn results_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
259 let query = text(&request.carried, "q");
260 let found = query.map(|query| look(state, query)).transpose()?;
261 let slot = results(found.as_ref());
262 Ok(Response::fragment(RESULTS, Node::Region(slot)))
263 }
264
265 /// The search screen's routes.
266 #[must_use]
267 pub fn routes(router: Router<AppState>) -> Router<AppState> {
268 router
269 .get("/search", index)
270 .get("/search/results", results_only)
271 }
272