Skip to main content

max / audiofiles

57.4 KB · 1641 lines History Blame Raw
1 //! The tag classifier, described: rules, auto-tagging, clustering, folder tags
2 //! and the `.afcl` files that carry all of it between libraries.
3 //!
4 //! The seventeenth port and the largest by a distance: `ui/classifier.rs` was
5 //! 1,432 lines and is deleted with this one.
6 //!
7 //! # It is not a settings section, and Max ruled that before a route was written
8 //!
9 //! It arrived as one because `draw_settings_panel` called it between Display
10 //! and License, and describing it there would have been repeating an accident
11 //! of where the call sat. The tree had already half-answered the question: the
12 //! review queue this screen launches is `/review`, a top-level address served
13 //! by [`queue`](super::queue) and taking the whole browser area, and every other
14 //! subsystem here — [`edit`](super::edit), [`forge`](super::forge),
15 //! [`export`](super::export) — has an address and a window of its own.
16 //!
17 //! **Ruled by Max: one screen at `/classifier`, five sections inside it, with
18 //! the door in Settings.** Not five addresses with a navigation between them:
19 //! the five are read together and the rules editor is the only one that takes
20 //! the body over.
21 //!
22 //! The door is a `GET`, which is what the toolbar's own Settings, Cloud Sync
23 //! and Help acts are, and it lands where they land: the shell runtime navigates
24 //! and the screen is drawn inline. `draw_settings` and `draw_sync` are windows
25 //! for the same screens and neither is reachable — the doors that set their
26 //! flags were the shipped sidebar's and went with `49b7429`. That is a hole
27 //! this port found rather than one it made, and adding a sixth window behind a
28 //! flag nothing sets would have been a third copy of it.
29 //!
30 //! # Every control is an intent, without a single exception
31 //!
32 //! Unusual, and it falls out of the app rather than being a choice: all
33 //! thirty-odd `classifier_*` methods on `BrowserState` take `&mut self`, and
34 //! several of them start work on another thread. So there is no handle to call
35 //! the way [`naming`](super::naming) calls `create_vfs`, and the rule about
36 //! *what the app does about a write* never has to be applied here.
37 //!
38 //! # The screen is handed the rule vocabulary rather than holding it
39 //!
40 //! [`Classifier::testable`] is the member that shape depends on. `FIELDS`,
41 //! `ops_for` and `op_needs_value` were in the drawing file, which put "what a
42 //! rule can ask about a sample" one `RuleField` away from having to be added in
43 //! two places. They are the app's now, and the description renders what it is
44 //! given: the field select lists what came back, and the operator select lists
45 //! what *that field* came back with. A described screen that narrowed the
46 //! operators itself would be a second copy of `audiofiles_core::rules`.
47 //!
48 //! # THE FINDING, and what closed it: a repeating question repeated one field
49 //!
50 //! The task filed against this port named [`Field::repeats`] as the member to
51 //! read first, on the reading that a rule's conditions are one question
52 //! answered N times. **Counted, they are not.** A condition is a testable, a
53 //! comparison and an operand — three questions whose answers only mean anything
54 //! together — and [`Repeat`] holds a `Vec<Instance>` where an instance is one
55 //! value and one error. An action is two questions the same way.
56 //!
57 //! So the editor could not use it, and what it lost was stated here: a renderer
58 //! had no way to know the regions were slots of one repeating question, so
59 //! nothing could number them or draw them as a list that grows, and "at least
60 //! one condition" was this file disabling its own last Remove.
61 //!
62 //! **Closed by quasicoherent `f7abbc08`** (Max chose c): [`Slot::repeating`]
63 //! says a region's children are answers to one question, and [`Slot::removes`]
64 //! says what takes one away. `Repeat` is untouched — it is still a repeating
65 //! *field*, and this is a repeating *group*. Both groups here say it now, and
66 //! the floor is [`Repeating::least`] rather than an `unless` in this file.
67 //!
68 //! Two consumers here, not one: conditions (three fields) and actions (two).
69 //! Both are in the same editor, which is what made this a shape rather than a
70 //! quirk of one screen, and what the ruling was measured against.
71 //!
72 //! # THE SECOND FINDING: a description cannot say "write when the control settles"
73 //!
74 //! The shipped thresholds and the layer weights both commit through
75 //! `widgets::settled`, so a drag across a slider is one write rather than one
76 //! per frame. [`Field::writes`] is the only thing the vocabulary has and it
77 //! means every change. Three sliders here take it, so a drag is now a write per
78 //! step: correct, and chattier than the shipped screen against a store that is
79 //! local.
80 //!
81 //! Not worked around, because the workaround would be worse — a submit button
82 //! per slider, or a debounce invented in one renderer. Filed with a count.
83 //!
84 //! # Two `ConfirmAction`-shaped state machines die here
85 //!
86 //! `pending_layer_remove` armed a layer's Remove and swapped the row for a
87 //! Cancel/Remove pair, exactly as `trash_confirm_purge` did before
88 //! [`trash`](super::trash) deleted it. [`Act::confirm`] again, and the field
89 //! goes with it. That is the fifth and sixth variant of that pattern this port
90 //! has replaced with a builder call.
91 //!
92 //! # Both pickers are described, and the export took the longer road
93 //!
94 //! Export writes a file the reader names a place for, and import reads one.
95 //!
96 //! **Import** takes a *path*: `classifier_import_afcl` wants one, so
97 //! [`Outcome::Locate`]'s `Sought::File` covers it exactly.
98 //! [`import`](fn@import) asks for an `.afcl` and [`imported`](fn@imported)
99 //! reads it, which is the act shape [`advanced`](super::advanced)'s Import
100 //! Theme uses.
101 //!
102 //! **Export** stayed an intent until quasi 0.63, and this header said it always
103 //! would. It is a save dialog: the reader is naming a file that does not exist
104 //! yet, and `Sought` could only ask where something already was.
105 //! [`Outcome::File`] is not the answer either, though
106 //! [`settings`](super::settings)'s theme export is that member's consumer here:
107 //! the classifier's export is built on a worker thread and answers through
108 //! `BackendEvent`, so the route has no bytes to hand over. `7fda7ae3` added `Sought::Save`, which is
109 //! that dialog said from the description's end: [`export`](fn@export) suggests
110 //! the name and [`exported`](fn@exported) writes to wherever the reader put it.
111 //! The sanitising moved with it, into [`export_filename`](fn@export_filename):
112 //! the suggestion is a description fact now, and what the reader typed into the
113 //! sharing field is not a file name until that has been over it.
114 //!
115 //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
116 //!
117 //! [`Act::confirm`]: quasi_router::Act::confirm
118 //! [`Classifier::testable`]: super::Classifier::testable
119 //! [`Field::writes`]: quasi_router::Field::writes
120 //! [`Field::repeats`]: quasi_router::Field::repeats
121 //! [`Outcome::File`]: quasi_router::Outcome::File
122 //! [`Repeat`]: quasi_router::Repeat
123 //! [`Repeating::least`]: quasi_router::Repeating::least
124 //! [`Slot::removes`]: quasi_router::Slot::removes
125 //! [`Slot::repeating`]: quasi_router::Slot::repeating
126
127 use quasi_declare::declare;
128 use quasi_router::layout::Tone;
129 use quasi_router::{
130 Accepted, Action, Choice, Locating, Request, Response, RouteError, Router, Sought, Tag,
131 };
132
133 use super::{Panels, Part, Shareable};
134
135 /// The region the whole screen answers into.
136 const BODY: &str = "classifier-body";
137
138 /// The suffix a shared classifier is written under.
139 const SHARED: &str = ".afcl";
140
141 /// The name a picked file comes back under.
142 const FILE: &str = "file";
143
144 /// What removing a layer takes with it.
145 const DROPS_LAYER: &str =
146 "Permanently delete this layer and its imported rules? This cannot be undone.";
147
148 /// Register the classifier's routes.
149 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
150 let router = router
151 .get("/classifier", index)
152 .post("/classifier/rules/new", new_rule)
153 .post("/classifier/rules/apply", apply_rules)
154 .post("/classifier/rules/starter", seed_rules)
155 .post("/classifier/rules/{id}/enabled", enable_rule)
156 .post("/classifier/rules/{id}/edit", edit_rule)
157 .post("/classifier/rules/{id}/delete", delete_rule)
158 .post("/classifier/rules/{id}/up", move_up)
159 .post("/classifier/rules/{id}/down", move_down);
160
161 let router = router
162 .post("/classifier/draft/name", name_draft)
163 .post("/classifier/draft/enabled", enable_draft)
164 .post("/classifier/draft/match", match_mode)
165 .post("/classifier/draft/conditions/add", add_condition)
166 .post("/classifier/draft/conditions/{at}/remove", drop_condition)
167 .post(
168 "/classifier/draft/conditions/{at}/set/{part}",
169 set_condition,
170 )
171 .post("/classifier/draft/actions/add", add_action)
172 .post("/classifier/draft/actions/{at}/remove", drop_action)
173 .post("/classifier/draft/actions/{at}/set/{part}", set_action)
174 .post("/classifier/draft/test", test_draft)
175 .post("/classifier/draft/save", save_draft)
176 .post("/classifier/draft/cancel", cancel_draft);
177
178 let router = router
179 .post("/classifier/autotag/suggest", suggest)
180 .post("/classifier/autotag/review", review)
181 .post("/classifier/autotag/reopen", reopen)
182 .post("/classifier/head/train", train)
183 .post("/classifier/head/clear", clear_head)
184 .post("/classifier/policies/typing", typing_policy)
185 .post("/classifier/policies/add", add_policy)
186 .post("/classifier/policies/{tag}/thresholds", set_policy);
187
188 let router = router
189 .post("/classifier/clusters/k", cluster_k)
190 .post("/classifier/clusters/find", find_clusters)
191 .post("/classifier/clusters/{at}/play", play_cluster)
192 .post("/classifier/clusters/{at}/name", name_cluster)
193 .post("/classifier/clusters/{at}/tag", tag_cluster)
194 .post("/classifier/folders/scan", scan_folders)
195 .post("/classifier/folders/{at}/tag", name_folder)
196 .post("/classifier/folders/{at}/apply", apply_folder)
197 .post("/classifier/undo/{source}", undo_source);
198
199 router
200 .post("/classifier/sharing/name", name_export)
201 .post("/classifier/sharing/include/{part}", include)
202 .post("/classifier/sharing/export", export)
203 .post("/classifier/sharing/exported", exported)
204 .post("/classifier/sharing/import", import)
205 .post("/classifier/sharing/imported", imported)
206 .post("/classifier/layers/{id}/enabled", enable_layer)
207 .post("/classifier/layers/{id}/weight", weigh_layer)
208 .post("/classifier/layers/{id}/remove", remove_layer)
209 }
210
211 // --- Rules -------------------------------------------------------------------
212
213 /// `GET /classifier`
214 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
215 Ok(showing(state))
216 }
217
218 /// `POST /classifier/rules/new`
219 fn new_rule(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
220 state.classifier.new_rule();
221 settled(state)
222 }
223
224 /// `POST /classifier/rules/apply`
225 fn apply_rules(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
226 state.classifier.apply_rules();
227 settled(state)
228 }
229
230 /// `POST /classifier/rules/starter`
231 fn seed_rules(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
232 state.classifier.seed_rules();
233 settled(state)
234 }
235
236 /// `POST /classifier/rules/{id}/enabled`
237 fn enable_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
238 let id = named(&request, "id")?;
239 state.classifier.enable_rule(id, ticked(&request, "on"));
240 settled(state)
241 }
242
243 /// `POST /classifier/rules/{id}/edit`
244 fn edit_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
245 state.classifier.edit_rule(named(&request, "id")?);
246 settled(state)
247 }
248
249 /// `POST /classifier/rules/{id}/delete`
250 fn delete_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
251 state.classifier.delete_rule(named(&request, "id")?);
252 settled(state)
253 }
254
255 /// `POST /classifier/rules/{id}/up`
256 fn move_up(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
257 state.classifier.move_rule(named(&request, "id")?, true);
258 settled(state)
259 }
260
261 /// `POST /classifier/rules/{id}/down`
262 fn move_down(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
263 state.classifier.move_rule(named(&request, "id")?, false);
264 settled(state)
265 }
266
267 // --- The rule being authored -------------------------------------------------
268
269 /// `POST /classifier/draft/name`
270 fn name_draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
271 state
272 .classifier
273 .name_draft(request.payload.get("name").unwrap_or_default());
274 settled(state)
275 }
276
277 /// `POST /classifier/draft/enabled`
278 fn enable_draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
279 state.classifier.enable_draft(ticked(&request, "enabled"));
280 settled(state)
281 }
282
283 /// `POST /classifier/draft/match`
284 fn match_mode(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
285 let answered = request.payload.get("match").unwrap_or_default();
286 state.classifier.match_all(answered != "any");
287 settled(state)
288 }
289
290 /// `POST /classifier/draft/conditions/add`
291 fn add_condition(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
292 state.classifier.add_condition();
293 settled(state)
294 }
295
296 /// `POST /classifier/draft/conditions/{at}/remove`
297 fn drop_condition(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
298 state.classifier.drop_condition(slot(&request)?);
299 settled(state)
300 }
301
302 /// `POST /classifier/draft/conditions/{at}/set/{part}`
303 fn set_condition(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
304 let at = slot(&request)?;
305 let part = condition_part(&request)?;
306 let name = request.captures.require("part")?;
307 state
308 .classifier
309 .set_condition(at, part, request.payload.get(name).unwrap_or_default());
310 settled(state)
311 }
312
313 /// `POST /classifier/draft/actions/add`
314 fn add_action(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
315 state.classifier.add_action();
316 settled(state)
317 }
318
319 /// `POST /classifier/draft/actions/{at}/remove`
320 fn drop_action(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
321 state.classifier.drop_action(slot(&request)?);
322 settled(state)
323 }
324
325 /// `POST /classifier/draft/actions/{at}/set/{part}`
326 fn set_action(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
327 let at = slot(&request)?;
328 let name = request.captures.require("part")?;
329 let part = match name {
330 "kind" => Part::Kind,
331 "tag" => Part::Value,
332 _ => return Err(RouteError::not_found("no such part of an action")),
333 };
334 state
335 .classifier
336 .set_action(at, part, request.payload.get(name).unwrap_or_default());
337 settled(state)
338 }
339
340 /// `POST /classifier/draft/test`
341 fn test_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
342 state.classifier.test_draft();
343 settled(state)
344 }
345
346 /// `POST /classifier/draft/save`
347 fn save_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
348 state.classifier.save_draft();
349 settled(state)
350 }
351
352 /// `POST /classifier/draft/cancel`
353 fn cancel_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
354 state.classifier.cancel_draft();
355 settled(state)
356 }
357
358 // --- Auto-tagging ------------------------------------------------------------
359
360 /// `POST /classifier/autotag/suggest`
361 fn suggest(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
362 state.classifier.suggest();
363 settled(state)
364 }
365
366 /// `POST /classifier/undo/{source}`
367 ///
368 /// One route for the three undo controls, because they are one question with
369 /// three answers: which pass is being taken back. The shipped screen had the
370 /// same string in three places.
371 fn undo_source(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
372 let source = request.captures.require("source")?;
373 if !matches!(source, "ml" | "cluster" | "harvest") {
374 return Err(RouteError::not_found("nothing applied tags that way"));
375 }
376 state.classifier.undo_source(source);
377 settled(state)
378 }
379
380 /// `POST /classifier/autotag/review`
381 fn review(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
382 state.classifier.review();
383 settled(state)
384 }
385
386 /// `POST /classifier/autotag/reopen`
387 fn reopen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
388 if state.classifier.waiting() == 0 {
389 return Err(RouteError::not_found("nothing is waiting"));
390 }
391 state.classifier.reopen_review();
392 settled(state)
393 }
394
395 /// `POST /classifier/head/train`
396 fn train(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
397 state.classifier.train();
398 settled(state)
399 }
400
401 /// `POST /classifier/head/clear`
402 fn clear_head(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
403 state.classifier.clear_head();
404 settled(state)
405 }
406
407 /// `POST /classifier/policies/{tag}/thresholds`
408 ///
409 /// Both ends in one call. They are two controls and one row, and a tag whose
410 /// review threshold sat above its auto threshold for the length of a round trip
411 /// would be a state the store should never see.
412 fn set_policy(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
413 let tag = request.captures.require("tag")?;
414 let current = state
415 .classifier
416 .policies()
417 .into_iter()
418 .find(|policy| policy.tag == tag)
419 .ok_or_else(|| RouteError::not_found("no threshold for that tag"))?;
420 let review = number(&request, "review").unwrap_or(current.review);
421 let auto = number(&request, "auto").unwrap_or(current.auto);
422 state.classifier.set_policy(tag, review, auto);
423 settled(state)
424 }
425
426 /// `POST /classifier/policies/typing`
427 fn typing_policy(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
428 state
429 .classifier
430 .typing_policy(request.payload.get("tag").unwrap_or_default());
431 settled(state)
432 }
433
434 /// `POST /classifier/policies/add`
435 fn add_policy(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
436 state.classifier.add_policy();
437 settled(state)
438 }
439
440 // --- Clusters and folders ----------------------------------------------------
441
442 /// `POST /classifier/clusters/k`
443 fn cluster_k(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
444 let asked = request
445 .payload
446 .get("cluster_k")
447 .and_then(|value| value.parse().ok())
448 .ok_or_else(|| RouteError::not_found("that is not a number of groups"))?;
449 state.classifier.set_cluster_k(asked);
450 settled(state)
451 }
452
453 /// `POST /classifier/clusters/find`
454 fn find_clusters(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
455 state.classifier.find_clusters();
456 settled(state)
457 }
458
459 /// `POST /classifier/clusters/{at}/play`
460 fn play_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
461 state.classifier.play_cluster(slot(&request)?);
462 settled(state)
463 }
464
465 /// `POST /classifier/clusters/{at}/name`
466 fn name_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
467 let at = slot(&request)?;
468 state
469 .classifier
470 .name_cluster(at, request.payload.get("name").unwrap_or_default());
471 settled(state)
472 }
473
474 /// `POST /classifier/clusters/{at}/tag`
475 fn tag_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
476 state.classifier.tag_cluster(slot(&request)?);
477 settled(state)
478 }
479
480 /// `POST /classifier/folders/scan`
481 fn scan_folders(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
482 state.classifier.scan_folders();
483 settled(state)
484 }
485
486 /// `POST /classifier/folders/{at}/tag`
487 fn name_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
488 let at = slot(&request)?;
489 state
490 .classifier
491 .name_folder(at, request.payload.get("tag").unwrap_or_default());
492 settled(state)
493 }
494
495 /// `POST /classifier/folders/{at}/apply`
496 fn apply_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
497 state.classifier.apply_folder(slot(&request)?);
498 settled(state)
499 }
500
501 // --- Sharing -----------------------------------------------------------------
502
503 /// `POST /classifier/sharing/name`
504 fn name_export(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
505 state
506 .classifier
507 .name_export(request.payload.get("export_name").unwrap_or_default());
508 settled(state)
509 }
510
511 /// `POST /classifier/sharing/include/{part}`
512 fn include(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
513 let name = request.captures.require("part")?;
514 let part = match name {
515 "exemplars" => Shareable::Exemplars,
516 "rules" => Shareable::Rules,
517 "thresholds" => Shareable::Thresholds,
518 _ => return Err(RouteError::not_found("nothing of that name is shared")),
519 };
520 state.classifier.include(part, ticked(&request, name));
521 settled(state)
522 }
523
524 /// `POST /classifier/sharing/export`
525 ///
526 /// Says a destination is wanted and leaves the dialog to the host. Writing the
527 /// file is [`exported`](fn@exported)'s, on the answer.
528 ///
529 /// The name is a suggestion, which is the whole reason this is `Sought::Save`
530 /// and not a folder ask with a name appended: the reader typed
531 /// [`export_name`](fn@export_name) into the sharing section and gets to change
532 /// it again in the dialog.
533 fn export(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
534 let sharing = state.classifier.sharing();
535 if !sharing.worth_writing() {
536 return Err(RouteError::not_found("there is nothing to export"));
537 }
538 Ok(Response::locate(Locating::new(
539 Sought::Save {
540 name: format!("{}{SHARED}", export_filename(&sharing.name)),
541 accept: vec![Accepted::suffix(SHARED)],
542 },
543 "Export classifier",
544 Action::post("/classifier/sharing/exported"),
545 FILE,
546 )))
547 }
548
549 /// `POST /classifier/sharing/exported`
550 ///
551 /// The same second guard [`imported`](fn@imported) carries, for the same reason:
552 /// an empty answer means the reader backed out, and the address is reachable by
553 /// typing.
554 fn exported(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
555 let file = request.payload.get(FILE).unwrap_or_default();
556 if !file.is_empty() {
557 state.classifier.export_to(file);
558 }
559 settled(state)
560 }
561
562 /// A file name an export can be written under.
563 ///
564 /// Lifted verbatim from the deleted `ui/classifier.rs`, where it was
565 /// `sanitize_filename`, underscore and all. It sits on the description side
566 /// because the name is now something the description says: `Sought::Save` asks
567 /// the host to offer it, and what the reader typed into the sharing field is not
568 /// a file name until this has been over it.
569 fn export_filename(name: &str) -> String {
570 let cleaned: String = name
571 .trim()
572 .chars()
573 .map(|c| {
574 if c.is_alphanumeric() || c == '-' || c == '_' {
575 c
576 } else {
577 '_'
578 }
579 })
580 .collect();
581 if cleaned.is_empty() {
582 "classifier".to_owned()
583 } else {
584 cleaned
585 }
586 }
587
588 /// `POST /classifier/sharing/import`
589 ///
590 /// Says a file is wanted and leaves the picker to the host. Reading it is
591 /// [`imported`](fn@imported)'s, on the answer.
592 fn import(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
593 Ok(Response::locate(Locating::new(
594 Sought::File {
595 accept: vec![Accepted::suffix(SHARED)],
596 },
597 "Import classifier",
598 Action::post("/classifier/sharing/imported"),
599 FILE,
600 )))
601 }
602
603 /// `POST /classifier/sharing/imported`
604 ///
605 /// A reader who backed out of the picker has answered nothing: `ui::dialog`
606 /// skips its handler on an empty result, so this is the second guard rather than
607 /// the only one, and it is here because the address is reachable by typing.
608 fn imported(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
609 let file = request.payload.get(FILE).unwrap_or_default();
610 if !file.is_empty() {
611 state.classifier.import(file);
612 }
613 settled(state)
614 }
615
616 /// `POST /classifier/layers/{id}/enabled`
617 fn enable_layer(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
618 let id = named(&request, "id")?;
619 state.classifier.enable_layer(id, ticked(&request, "on"));
620 settled(state)
621 }
622
623 /// `POST /classifier/layers/{id}/weight`
624 fn weigh_layer(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
625 let id = named(&request, "id")?;
626 let weight =
627 number(&request, "weight").ok_or_else(|| RouteError::not_found("that is not a weight"))?;
628 state.classifier.weigh_layer(id, weight);
629 settled(state)
630 }
631
632 /// `POST /classifier/layers/{id}/remove`
633 fn remove_layer(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
634 state.classifier.remove_layer(named(&request, "id")?);
635 settled(state)
636 }
637
638 // --- Reading a request -------------------------------------------------------
639
640 /// The screen again, which is what every act here answers with.
641 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
642 Ok(showing(state))
643 }
644
645 /// A captured name, refused when it is not there.
646 fn named<'a>(request: &'a Request, capture: &str) -> Result<&'a str, RouteError> {
647 request.captures.require(capture)
648 }
649
650 /// The slot an address names.
651 fn slot(request: &Request) -> Result<usize, RouteError> {
652 request
653 .captures
654 .require("at")?
655 .parse()
656 .map_err(|_| RouteError::not_found("that is not a slot"))
657 }
658
659 /// Which part of a condition an address names.
660 fn condition_part(request: &Request) -> Result<Part, RouteError> {
661 match request.captures.require("part")? {
662 "field" => Ok(Part::Field),
663 "op" => Ok(Part::Op),
664 "value" => Ok(Part::Value),
665 _ => Err(RouteError::not_found("no such part of a condition")),
666 }
667 }
668
669 /// Whether a checkbox came back ticked.
670 ///
671 /// A renderer sends the name with a value when the box is on and sends nothing
672 /// when it is off, which is the shape every `Field::writes` checkbox in this
673 /// app already answers with.
674 fn ticked(request: &Request, name: &str) -> bool {
675 !request.payload.get(name).unwrap_or_default().is_empty()
676 }
677
678 /// A number a request carries, if it carries one.
679 fn number(request: &Request, name: &str) -> Option<f64> {
680 request.payload.get(name)?.parse().ok()
681 }
682
683 // --- The screen --------------------------------------------------------------
684 // --- The screen --------------------------------------------------------------
685
686 /// The screen, read and then described.
687 fn showing(state: &Panels<'_>) -> Response {
688 Response::from(screen(&read(state)))
689 }
690
691 /// The whole window: five sections, and the rules editor that takes one over.
692 struct Classifying {
693 /// What is running, while something is.
694 ///
695 /// What the shipped section put above everything for the same reason: the
696 /// footer status line was hidden behind the Settings modal, so a running job
697 /// and a failed one had to say so here.
698 doing: Option<String>,
699 /// What went wrong, while nothing is running.
700 failed: Option<String>,
701 /// Whether anything is running, which deadens most of this screen.
702 busy: bool,
703 rules: Rules,
704 autotag: Autotag,
705 clusters: Clusters,
706 folders: Folders,
707 sharing: Shared,
708 }
709
710 /// Tag Rules: the list, or the editor when one is open.
711 struct Rules {
712 /// The rules as they stand, while none is being authored.
713 listing: Option<RuleList>,
714 /// The rule being authored, in place of the list.
715 editor: Option<Editor>,
716 }
717
718 /// The rules as they stand.
719 struct RuleList {
720 /// What the last apply changed, where there has been one.
721 last: Option<String>,
722 /// Whether there are no rules at all.
723 bare: bool,
724 each: Vec<Listed>,
725 }
726
727 /// One rule in the list.
728 struct Listed {
729 id: String,
730 /// What it is called, or that it is not.
731 name: String,
732 /// How much it tests and how much it does.
733 meta: String,
734 enabled: bool,
735 /// Whether it is already the first, and whether it is already the last.
736 first: bool,
737 last: bool,
738 /// What deleting it takes with it, and what it does not.
739 confirm: String,
740 }
741
742 /// The rule being authored.
743 struct Editor {
744 name: String,
745 /// Why the name was refused, while it was.
746 ///
747 /// Gated on a refusal rather than on emptiness: a freshly opened editor
748 /// complaining about a name nobody has typed is scolding an empty form.
749 problem: Option<&'static str>,
750 enabled: &'static str,
751 /// Whether every condition must hold, or any one of them.
752 matching: &'static str,
753 conditions: Vec<Condition>,
754 actions: Vec<Doing>,
755 /// What the last test counted, where there has been one.
756 matched: Option<String>,
757 }
758
759 /// One condition, as three questions grouped.
760 struct Condition {
761 /// Its position, which is its region's name and its address.
762 at: usize,
763 /// Every field a rule may test.
764 fields: Vec<Choice>,
765 field: String,
766 /// Only the comparisons this field allows, which is the whole reason the
767 /// app hands over the vocabulary rather than the screen holding it.
768 ops: Vec<Choice>,
769 op: String,
770 /// The operand, only where the comparison wants one.
771 ///
772 /// `op_needs_value` said in the description instead of enforced while
773 /// drawing.
774 value: Option<String>,
775 }
776
777 /// One action, as two questions grouped.
778 struct Doing {
779 /// Its position, which is its region's name and its address.
780 at: usize,
781 kind: String,
782 /// The tag, except where the action is Stop.
783 tag: Option<String>,
784 }
785
786 /// Auto-Tagging: the pass, the review door, the head, and the thresholds.
787 struct Autotag {
788 /// What the last run applied, where there has been one.
789 last: Option<String>,
790 /// What the review control reads while a queue is already waiting.
791 ///
792 /// Re-entry without a rescan: the queue outlives the screen, and a pass over
793 /// a large library costs seconds.
794 reopen: Option<String>,
795 /// What the last review said, where there has been one.
796 reviewed: Option<String>,
797 head: Head,
798 thresholds: Thresholds,
799 }
800
801 /// The optional trained head.
802 struct Head {
803 /// What it holds, or what training one would be worth.
804 ///
805 /// The size hint is only worth showing while there is no model, which is
806 /// why one field answers both.
807 said: Option<String>,
808 /// What the training control reads, which is where it says whether there is
809 /// already a model.
810 train: &'static str,
811 /// Whether there is a model to clear.
812 trained: bool,
813 }
814
815 /// The per-tag thresholds.
816 struct Thresholds {
817 each: Vec<Threshold>,
818 /// The tag being named for a threshold it does not have yet.
819 typing: String,
820 }
821
822 /// One tag's two thresholds.
823 struct Threshold {
824 tag: String,
825 review: String,
826 auto: String,
827 }
828
829 /// Clustering: the cold-start grouping.
830 struct Clusters {
831 /// How many groups to ask for.
832 k: String,
833 piles: Vec<Pile>,
834 }
835
836 /// One group of similar samples.
837 struct Pile {
838 /// Its position, which is its region's name and its address.
839 at: usize,
840 /// How many samples are in it.
841 said: String,
842 name: String,
843 /// Whether its representative is still there.
844 ///
845 /// The representative can be gone: deleting a sample must not take the pile
846 /// or its name with it.
847 playable: bool,
848 /// Whether it has been named, which is what wakes Tag.
849 named: bool,
850 }
851
852 /// Folder Tags: turn directories that hold samples into tags.
853 struct Folders {
854 /// Whether a scan happened and found nothing.
855 ///
856 /// Never scanned is not the same as a scan that found nothing, and the
857 /// shipped section drew neither in that state, so this is false in both the
858 /// unscanned case and the case with folders to show.
859 bare: bool,
860 each: Vec<Harvested>,
861 }
862
863 /// One folder that directly holds samples.
864 struct Harvested {
865 /// Its position, which is its region's name and its address.
866 at: usize,
867 /// What it is called, and how much is in it.
868 said: String,
869 tag: String,
870 /// Whether a tag has been typed, which is what wakes Apply.
871 named: bool,
872 }
873
874 /// Shared Classifiers: export, import, and the layers an import leaves.
875 struct Shared {
876 /// What the export file will be called.
877 name: String,
878 /// The three parts, each ticked or not.
879 parts: Vec<Included>,
880 /// Whether there is anything worth writing.
881 worth: bool,
882 /// What the last export said, where there has been one.
883 exported: Option<String>,
884 /// What the last import said, where there has been one.
885 imported: Option<String>,
886 layers: Vec<Layer>,
887 }
888
889 /// One kind of thing an export may carry.
890 struct Included {
891 name: &'static str,
892 label: &'static str,
893 /// On or off, as the control carries it.
894 value: &'static str,
895 /// Said rather than silently contributing nothing: a part that is ticked
896 /// and does not exist is why `worth_writing` asks about both.
897 hint: Option<&'static str>,
898 }
899
900 /// One imported layer.
901 struct Layer {
902 id: String,
903 name: String,
904 /// What it brought with it.
905 said: String,
906 enabled: &'static str,
907 /// How much it counts next to your own tagging.
908 weight: String,
909 }
910
911 /// What the screen draws, read off the app.
912 fn read(state: &Panels<'_>) -> Classifying {
913 let doing = state.classifier.busy();
914 let busy = doing.is_some();
915 Classifying {
916 // A failure is only worth reporting while nothing is running, which is
917 // what the shipped section's `else if` said.
918 failed: (!busy).then(|| state.classifier.failed()).flatten(),
919 doing,
920 busy,
921 rules: rules_read(state),
922 autotag: autotag_read(state),
923 clusters: clusters_read(state),
924 folders: folders_read(state),
925 sharing: sharing_read(state),
926 }
927 }
928
929 /// The rules, or the one being authored in place of them.
930 fn rules_read(state: &Panels<'_>) -> Rules {
931 let authoring = state.classifier.authoring();
932 Rules {
933 listing: authoring.is_none().then(|| rule_list_read(state)),
934 editor: authoring.map(|draft| editor_read(state, &draft)),
935 }
936 }
937
938 /// The rules as they stand.
939 fn rule_list_read(state: &Panels<'_>) -> RuleList {
940 let all = state.classifier.rules();
941 RuleList {
942 last: state
943 .classifier
944 .last_apply()
945 .map(|changed| format!("Last apply: {changed} sample{} updated", plural(changed))),
946 bare: all.is_empty(),
947 each: all
948 .iter()
949 .map(|rule| {
950 let named = if rule.name.trim().is_empty() {
951 "(unnamed)"
952 } else {
953 rule.name.trim()
954 };
955 Listed {
956 id: rule.id.clone(),
957 name: named.to_owned(),
958 meta: format!("{} cond \u{2192} {} act", rule.conditions, rule.actions),
959 enabled: rule.enabled,
960 first: rule.first,
961 last: rule.last,
962 confirm: format!(
963 "Delete the rule \"{named}\"? Tags it already applied stay where they are."
964 ),
965 }
966 })
967 .collect(),
968 }
969 }
970
971 /// The rule being authored, read off the draft.
972 fn editor_read(state: &Panels<'_>, draft: &super::Authoring) -> Editor {
973 let testable = state.classifier.testable();
974 Editor {
975 name: draft.name.clone(),
976 problem: (draft.refused && draft.name.trim().is_empty())
977 .then_some("Name the rule before saving."),
978 enabled: switched(draft.enabled),
979 matching: if draft.all { "all" } else { "any" },
980 conditions: draft
981 .conditions
982 .iter()
983 .enumerate()
984 .map(|(at, condition)| condition_read(at, condition, &testable))
985 .collect(),
986 actions: draft
987 .actions
988 .iter()
989 .enumerate()
990 .map(|(at, action)| Doing {
991 at,
992 kind: action.kind.clone(),
993 tag: (action.kind != "stop").then(|| action.tag.clone()),
994 })
995 .collect(),
996 matched: draft
997 .matched
998 .map(|matched| format!("Matches {matched} sample{}", plural(matched))),
999 }
1000 }
1001
1002 /// One condition, and only the comparisons its field allows.
1003 fn condition_read(
1004 at: usize,
1005 condition: &super::Testing,
1006 testable: &[super::Testable],
1007 ) -> Condition {
1008 let chosen = testable.iter().find(|field| field.value == condition.field);
1009 let ops = chosen.map(|field| field.ops.as_slice()).unwrap_or_default();
1010 Condition {
1011 at,
1012 fields: testable
1013 .iter()
1014 .map(|field| Choice::new(field.value.clone(), field.label.clone()))
1015 .collect(),
1016 field: condition.field.clone(),
1017 ops: ops
1018 .iter()
1019 .map(|op| Choice::new(op.value.clone(), op.label.clone()))
1020 .collect(),
1021 op: condition.op.clone(),
1022 value: ops
1023 .iter()
1024 .find(|allowed| allowed.value == condition.op)
1025 .is_some_and(|allowed| allowed.takes_value)
1026 .then(|| condition.value.clone()),
1027 }
1028 }
1029
1030 /// Auto-tagging, read off what it has already done.
1031 fn autotag_read(state: &Panels<'_>) -> Autotag {
1032 let waiting = state.classifier.waiting();
1033 Autotag {
1034 last: state
1035 .classifier
1036 .last_suggest()
1037 .map(|applied| format!("Last run: {applied} tag{} applied", plural(applied))),
1038 reopen: (waiting > 0).then(|| format!("Reopen ({waiting})")),
1039 reviewed: state.classifier.last_review(),
1040 head: head_read(state),
1041 thresholds: Thresholds {
1042 each: state
1043 .classifier
1044 .policies()
1045 .iter()
1046 .map(|policy| Threshold {
1047 tag: policy.tag.clone(),
1048 review: format!("{:.2}", policy.review),
1049 auto: format!("{:.2}", policy.auto),
1050 })
1051 .collect(),
1052 typing: state.classifier.policy_typing(),
1053 },
1054 }
1055 }
1056
1057 /// The trained head, or the case for training one.
1058 fn head_read(state: &Panels<'_>) -> Head {
1059 let head = state.classifier.head();
1060 Head {
1061 said: match &head {
1062 Some(head) => Some(format!(
1063 "Active: {} tag{} from {} sample{}.",
1064 head.classes,
1065 plural(head.classes),
1066 head.exemplars,
1067 plural(head.exemplars),
1068 )),
1069 None => state.classifier.readiness().map(|ready| {
1070 if ready.worthwhile {
1071 format!(
1072 "No model yet. With {} tagged samples, training is recommended.",
1073 ready.tagged
1074 )
1075 } else {
1076 format!(
1077 "No model: nearest-neighbor matching is fast enough at {} tagged sample{}.",
1078 ready.tagged,
1079 plural(ready.tagged),
1080 )
1081 }
1082 }),
1083 },
1084 train: if head.is_some() {
1085 "Retrain model"
1086 } else {
1087 "Train model"
1088 },
1089 trained: head.is_some(),
1090 }
1091 }
1092
1093 /// The clustering section, read off what it has grouped.
1094 fn clusters_read(state: &Panels<'_>) -> Clusters {
1095 Clusters {
1096 k: state.classifier.cluster_k().to_string(),
1097 piles: state
1098 .classifier
1099 .clusters()
1100 .iter()
1101 .enumerate()
1102 .map(|(at, pile)| Pile {
1103 at,
1104 said: format!("{} sample{}", pile.members, plural(pile.members)),
1105 name: pile.name.clone(),
1106 playable: pile.playable,
1107 named: !pile.name.trim().is_empty(),
1108 })
1109 .collect(),
1110 }
1111 }
1112
1113 /// The folder-tags section, read off whatever the last scan found.
1114 fn folders_read(state: &Panels<'_>) -> Folders {
1115 let found = state.classifier.folders();
1116 Folders {
1117 bare: found.as_ref().is_some_and(Vec::is_empty),
1118 each: found
1119 .unwrap_or_default()
1120 .iter()
1121 .enumerate()
1122 .map(|(at, folder)| Harvested {
1123 at,
1124 said: format!(
1125 "{} ({} sample{})",
1126 folder.folder,
1127 folder.samples,
1128 plural(folder.samples)
1129 ),
1130 tag: folder.tag.clone(),
1131 named: !folder.tag.trim().is_empty(),
1132 })
1133 .collect(),
1134 }
1135 }
1136
1137 /// The sharing section, read off what there is to write and what came in.
1138 fn sharing_read(state: &Panels<'_>) -> Shared {
1139 let share = state.classifier.sharing();
1140 Shared {
1141 name: share.name.clone(),
1142 parts: [
1143 ("exemplars", "Exemplars", share.exemplars),
1144 ("rules", "Rules", share.rules),
1145 ("thresholds", "Thresholds", share.thresholds),
1146 ]
1147 .into_iter()
1148 .map(|(name, label, part)| Included {
1149 name,
1150 label,
1151 value: switched(part.wanted),
1152 hint: (!part.available).then_some("Nothing of this kind yet."),
1153 })
1154 .collect(),
1155 worth: share.worth_writing(),
1156 exported: share.exported.clone(),
1157 imported: share.imported.clone(),
1158 layers: state
1159 .classifier
1160 .layers()
1161 .iter()
1162 .map(|layer| Layer {
1163 id: layer.id.clone(),
1164 name: layer.name.clone(),
1165 said: format!(
1166 "{} ex \u{b7} {} rule{}",
1167 layer.exemplars,
1168 layer.rules,
1169 plural(layer.rules)
1170 ),
1171 enabled: switched(layer.enabled),
1172 weight: format!("{:.2}", layer.weight),
1173 })
1174 .collect(),
1175 }
1176 }
1177
1178 declare! {
1179 /// The whole window.
1180 ///
1181 /// One screen and five sections, which is Max's ruling: the five are read
1182 /// together and the rules editor is the only one that takes the body over.
1183 shape screen(classify: &Classifying) -> Screen;
1184
1185 screen sidebar_content "Tag classifier" {
1186 region BODY as Pane {
1187 page "Tag classifier";
1188 for doing in classify.doing.iter() {
1189 underway doing;
1190 }
1191 for failed in classify.failed.iter() {
1192 banner Tone::Warning failed;
1193 }
1194 extend rules(&classify.rules, classify.busy);
1195 extend autotag(&classify.autotag, classify.busy);
1196 extend clusters(&classify.clusters, classify.busy);
1197 extend folders(&classify.folders);
1198 extend sharing(&classify.sharing, classify.busy);
1199 }
1200 }
1201 }
1202
1203 declare! {
1204 /// Tag Rules: the list, or the editor when one is open.
1205 shape rules(rules: &Rules, busy: bool) -> Vec<Node>;
1206
1207 section "Tag Rules";
1208 text "Deterministic rules that auto-apply tags by sample metadata and audio \
1209 features. Rules never remove tags you added by hand.";
1210 for listing in rules.listing.iter() {
1211 extend rule_list(listing, busy);
1212 }
1213 for editor in rules.editor.iter() {
1214 extend rule_editor(editor);
1215 }
1216 }
1217
1218 declare! {
1219 /// The rules as they stand.
1220 shape rule_list(listing: &RuleList, busy: bool) -> Vec<Node>;
1221
1222 act "New rule" to post "/classifier/rules/new" {
1223 disabled when busy;
1224 }
1225 text "Re-evaluate every rule across the whole library.";
1226 act "Apply rules now" to post "/classifier/rules/apply" {
1227 disabled when busy;
1228 }
1229 text "Starter rules cover the common instrument and format words (Kick.wav \
1230 becomes instrument.drum.kick). They arrive disabled: review them, \
1231 then enable the ones you want.";
1232 act "Add starter rules" to post "/classifier/rules/starter" {
1233 disabled when busy;
1234 }
1235 for last in listing.last.iter() {
1236 text last;
1237 }
1238
1239 empty "No rules yet. New rules start empty; you decide what gets tagged."
1240 when listing.bare;
1241 list {
1242 for rule in listing.each.iter() {
1243 row &rule.name {
1244 meta &rule.meta;
1245 toggling rule.enabled Action::post("/classifier/rules/{rule.id}/enabled");
1246 token Tag::badge("off") unless rule.enabled;
1247 act "Edit" to post "/classifier/rules/{rule.id}/edit";
1248 act "Earlier" to post "/classifier/rules/{rule.id}/up" {
1249 disabled when rule.first;
1250 }
1251 act "Later" to post "/classifier/rules/{rule.id}/down" {
1252 disabled when rule.last;
1253 }
1254 act "Delete" to post "/classifier/rules/{rule.id}/delete" {
1255 tone Danger;
1256 confirm &rule.confirm;
1257 }
1258 }
1259 }
1260 } unless listing.bare;
1261 }
1262
1263 declare! {
1264 /// The rule being authored, in place of the list.
1265 shape rule_editor(editor: &Editor) -> Vec<Node>;
1266
1267 field Text "name" "Name" {
1268 required;
1269 placeholder "Kick drums";
1270 value &editor.name;
1271 writes Action::post("/classifier/draft/name");
1272 for &problem in editor.problem.iter() {
1273 error problem;
1274 }
1275 }
1276 field Checkbox "enabled" "Enabled" {
1277 value editor.enabled;
1278 writes Action::post("/classifier/draft/enabled");
1279 }
1280 field Radio "match" "Match" {
1281 option Choice::new("all", "All of these conditions");
1282 option Choice::new("any", "Any of these conditions");
1283 value editor.matching;
1284 writes Action::post("/classifier/draft/match");
1285 }
1286
1287 // The conditions and the actions, each as slots of one repeating question.
1288 // quasicoherent `f7abbc08`: the regions were always here and nothing said
1289 // they were slots, so no renderer could number them and "at least one
1290 // condition" was this file disabling its own last Remove.
1291 //
1292 // `least 1` is that rule, said once. The actions say no floor at all, which
1293 // is the honest answer for them: a rule with no actions is describable and
1294 // the editor has never stopped anyone writing one.
1295 section "When";
1296 region "conditions" as Group {
1297 repeats "Condition" adds "Add condition"
1298 to post "/classifier/draft/conditions/add" {
1299 least 1;
1300 }
1301 for held in editor.conditions.iter() {
1302 include condition(held);
1303 }
1304 }
1305
1306 section "Then";
1307 region "actions" as Group {
1308 repeats "Action" adds "Add action" to post "/classifier/draft/actions/add";
1309 for held in editor.actions.iter() {
1310 include doing(held);
1311 }
1312 }
1313
1314 text "Count how many samples match, without writing.";
1315 act "Test" to post "/classifier/draft/test";
1316 for matched in editor.matched.iter() {
1317 text matched;
1318 }
1319 act "Save" to post "/classifier/draft/save";
1320 act "Cancel" to post "/classifier/draft/cancel" {
1321 key "esc";
1322 }
1323 }
1324
1325 declare! {
1326 /// One condition, as three questions grouped: one slot of a repeating
1327 /// question.
1328 ///
1329 /// See the module header's first finding and what closed it. This region
1330 /// says what takes it away and its parent says how few may be left standing,
1331 /// so the floor is the description's rather than this file's.
1332 shape condition(condition: &Condition) -> Node;
1333
1334 region "condition-{condition.at}" as Group {
1335 field Select "field" "Test" {
1336 options condition.fields.clone();
1337 value &condition.field;
1338 writes Action::post("/classifier/draft/conditions/{condition.at}/set/field");
1339 }
1340 field Select "op" "Comparison" {
1341 options condition.ops.clone();
1342 value &condition.op;
1343 writes Action::post("/classifier/draft/conditions/{condition.at}/set/op");
1344 }
1345 for operand in condition.value.iter() {
1346 field Text "value" "Value" {
1347 value operand;
1348 writes Action::post("/classifier/draft/conditions/{condition.at}/set/value");
1349 }
1350 }
1351 // The last condition cannot be removed. This file used to say so by
1352 // disabling its own button; the parent's `least` says it now, and every
1353 // renderer draws it dead rather than hidden.
1354 removes "Remove condition"
1355 to post "/classifier/draft/conditions/{condition.at}/remove";
1356 }
1357 }
1358
1359 declare! {
1360 /// One action, as two questions grouped: one slot of a repeating question.
1361 shape doing(does: &Doing) -> Node;
1362
1363 region "action-{does.at}" as Group {
1364 field Select "kind" "Do" {
1365 option Choice::new("add", "Add tag");
1366 option Choice::new("remove", "Remove tag");
1367 option Choice::new("stop", "Stop");
1368 value &does.kind;
1369 writes Action::post("/classifier/draft/actions/{does.at}/set/kind");
1370 }
1371 for tag in does.tag.iter() {
1372 field Text "tag" "Tag" {
1373 placeholder "instrument.drum.kick";
1374 value tag;
1375 writes Action::post("/classifier/draft/actions/{does.at}/set/tag");
1376 }
1377 }
1378 removes "Remove action" to post "/classifier/draft/actions/{does.at}/remove";
1379 }
1380 }
1381
1382 declare! {
1383 /// Auto-Tagging: the pass, the review door, the head, and the thresholds.
1384 shape autotag(auto: &Autotag, busy: bool) -> Vec<Node>;
1385
1386 section "Auto-Tagging";
1387 text "Suggests tags from samples that look like ones you've already tagged. \
1388 Tags above a per-tag threshold are applied automatically.";
1389 act "Suggest tags across library" to post "/classifier/autotag/suggest" {
1390 disabled when busy;
1391 }
1392 act "Undo auto-tagging" to post "/classifier/undo/ml" {
1393 confirm "Remove every tag auto-tagging applied?";
1394 disabled when busy;
1395 }
1396 for last in auto.last.iter() {
1397 text last;
1398 }
1399
1400 section "Review suggestions";
1401 text "Collects what auto-tagging would apply, without applying any of it, \
1402 then opens a screen to accept it a tag at a time.";
1403 act "Review suggestions" to post "/classifier/autotag/review" {
1404 disabled when busy;
1405 }
1406 for reopen in auto.reopen.iter() {
1407 act reopen to post "/classifier/autotag/reopen" {
1408 disabled when busy;
1409 }
1410 }
1411 for reviewed in auto.reviewed.iter() {
1412 text reviewed;
1413 }
1414
1415 extend trained(&auto.head, busy);
1416 extend thresholds(&auto.thresholds);
1417 }
1418
1419 declare! {
1420 /// The optional trained head.
1421 shape trained(head: &Head, busy: bool) -> Vec<Node>;
1422
1423 section "Trained model";
1424 text "For large libraries, distil your tagged samples into a compact model \
1425 so auto-tagging runs much faster. Optional: auto-tagging works \
1426 without it.";
1427 for said in head.said.iter() {
1428 text said;
1429 }
1430 act head.train to post "/classifier/head/train" {
1431 disabled when busy;
1432 }
1433 act "Clear model" to post "/classifier/head/clear" when head.trained {
1434 confirm "Remove the model? Auto-tagging reverts to nearest-neighbor \
1435 matching.";
1436 disabled when busy;
1437 }
1438 }
1439
1440 declare! {
1441 /// The per-tag thresholds.
1442 shape thresholds(thresholds: &Thresholds) -> Vec<Node>;
1443
1444 section "Per-tag thresholds";
1445 text "review = surface for review \u{b7} auto = apply automatically";
1446 for held in thresholds.each.iter() {
1447 include threshold(held);
1448 }
1449 field Text "tag" "Tag to configure" {
1450 value &thresholds.typing;
1451 writes Action::post("/classifier/policies/typing");
1452 }
1453 act "Add threshold" to post "/classifier/policies/add";
1454 }
1455
1456 declare! {
1457 /// One tag's two thresholds.
1458 ///
1459 /// `Range` and not a validated number: the two ends *are* the question here,
1460 /// since 0 is never and 1 is only-on-certainty and a typed 0.72 says nothing
1461 /// without both of them on screen.
1462 shape threshold(policy: &Threshold) -> Node;
1463
1464 region "policy-{policy.tag}" as Group {
1465 text &policy.tag;
1466 field Range "review" "review" {
1467 within "0" "1";
1468 step "0.01";
1469 value &policy.review;
1470 writes Action::post("/classifier/policies/{policy.tag}/thresholds")
1471 .with("review", &policy.review);
1472 }
1473 field Range "auto" "auto" {
1474 within "0" "1";
1475 step "0.01";
1476 value &policy.auto;
1477 writes Action::post("/classifier/policies/{policy.tag}/thresholds")
1478 .with("auto", &policy.auto);
1479 }
1480 }
1481 }
1482
1483 declare! {
1484 /// Clustering: the cold-start grouping.
1485 shape clusters(clusters: &Clusters, busy: bool) -> Vec<Node>;
1486
1487 section "Clustering";
1488 text "Groups similar samples so you can name them and seed your first tags. \
1489 Useful when nothing is tagged yet.";
1490 // A whole number between two ends: 2 is the fewest groups worth having and
1491 // 24 is where the grouping stops telling you anything.
1492 field Range "cluster_k" "Groups" {
1493 within "2" "24";
1494 step "1";
1495 value &clusters.k;
1496 writes Action::post("/classifier/clusters/k");
1497 }
1498 act "Find clusters" to post "/classifier/clusters/find" {
1499 disabled when busy;
1500 }
1501 for held in clusters.piles.iter() {
1502 include pile(held);
1503 }
1504 act "Remove all cluster tags" to post "/classifier/undo/cluster" unless clusters.piles.is_empty() {
1505 confirm "Remove every tag applied from clustering?";
1506 }
1507 }
1508
1509 declare! {
1510 /// One group of similar samples.
1511 shape pile(pile: &Pile) -> Node;
1512
1513 region "cluster-{pile.at}" as Group {
1514 text &pile.said;
1515 field Text "name" "Name this group" {
1516 value &pile.name;
1517 writes Action::post("/classifier/clusters/{pile.at}/name");
1518 }
1519 act "Play" to post "/classifier/clusters/{pile.at}/play" {
1520 disabled unless pile.playable;
1521 }
1522 act "Tag" to post "/classifier/clusters/{pile.at}/tag" {
1523 disabled unless pile.named;
1524 }
1525 }
1526 }
1527
1528 declare! {
1529 /// Folder Tags: turn directories that hold samples into tags.
1530 shape folders(folders: &Folders) -> Vec<Node>;
1531
1532 section "Folder Tags";
1533 text "Turns folders that directly contain samples into tags. Useful when \
1534 your library is already organized into folders.";
1535 act "Scan folders" to post "/classifier/folders/scan";
1536 act "Undo folder tags" to post "/classifier/undo/harvest" {
1537 confirm "Remove every tag applied from folders?";
1538 }
1539 empty "No folders with samples found." when folders.bare;
1540 for held in folders.each.iter() {
1541 include harvested(held);
1542 }
1543 }
1544
1545 declare! {
1546 /// One folder that directly holds samples.
1547 shape harvested(folder: &Harvested) -> Node;
1548
1549 region "folder-{folder.at}" as Group {
1550 text &folder.said;
1551 field Text "tag" "Tag" {
1552 value &folder.tag;
1553 writes Action::post("/classifier/folders/{folder.at}/tag");
1554 }
1555 act "Apply" to post "/classifier/folders/{folder.at}/apply" {
1556 disabled unless folder.named;
1557 }
1558 }
1559 }
1560
1561 declare! {
1562 /// Shared Classifiers: export, import, and the layers an import leaves.
1563 shape sharing(shared: &Shared, busy: bool) -> Vec<Node>;
1564
1565 section "Shared Classifiers (.afcl)";
1566 text "Share your tagging as a portable .afcl file. It carries your feature \
1567 vectors, tags, rules, and thresholds, but never any audio. Imported \
1568 files become removable layers that sit below your own tagging.";
1569 field Text "export_name" "Name" {
1570 placeholder "My classifier";
1571 value &shared.name;
1572 writes Action::post("/classifier/sharing/name");
1573 }
1574 for part in shared.parts.iter() {
1575 field Checkbox part.name part.label {
1576 value part.value;
1577 writes Action::post("/classifier/sharing/include/{part.name}");
1578 for &said in part.hint.iter() {
1579 hint said;
1580 }
1581 }
1582 }
1583
1584 act "Export to file..." to post "/classifier/sharing/export" {
1585 disabled when busy or not shared.worth;
1586 }
1587 text "Nothing to export yet. Tag samples or add rules first." unless shared.worth;
1588 for exported in shared.exported.iter() {
1589 text exported;
1590 }
1591 act "Import .afcl file..." to post "/classifier/sharing/import" {
1592 disabled when busy;
1593 }
1594 for imported in shared.imported.iter() {
1595 text imported;
1596 }
1597
1598 section "Imported layers" unless shared.layers.is_empty();
1599 text "Imported rules arrive disabled. Review them in Tag Rules above before \
1600 enabling. Weight sets how much a layer counts next to your own \
1601 tagging." unless shared.layers.is_empty();
1602 for held in shared.layers.iter() {
1603 include layer(held);
1604 }
1605 }
1606
1607 declare! {
1608 /// One imported layer.
1609 shape layer(layer: &Layer) -> Node;
1610
1611 region "layer-{layer.id}" as Group {
1612 text &layer.name;
1613 text &layer.said;
1614 field Checkbox "on" "Use when auto-tagging" {
1615 value layer.enabled;
1616 writes Action::post("/classifier/layers/{layer.id}/enabled");
1617 }
1618 field Range "weight" "Weight" {
1619 within "0" "1";
1620 step "0.01";
1621 hint "Your own tags always count 1.0; lower means weaker.";
1622 value &layer.weight;
1623 writes Action::post("/classifier/layers/{layer.id}/weight");
1624 }
1625 act "Remove layer" to post "/classifier/layers/{layer.id}/remove" {
1626 tone Danger;
1627 confirm DROPS_LAYER;
1628 }
1629 }
1630 }
1631
1632 /// A switch as the control that sets it carries it.
1633 const fn switched(on: bool) -> &'static str {
1634 if on { "on" } else { "" }
1635 }
1636
1637 /// The plural `s`, or nothing.
1638 const fn plural(count: usize) -> &'static str {
1639 if count == 1 { "" } else { "s" }
1640 }
1641