//! The tag classifier, described: rules, auto-tagging, clustering, folder tags //! and the `.afcl` files that carry all of it between libraries. //! //! The seventeenth port and the largest by a distance: `ui/classifier.rs` was //! 1,432 lines and is deleted with this one. //! //! # It is not a settings section, and Max ruled that before a route was written //! //! It arrived as one because `draw_settings_panel` called it between Display //! and License, and describing it there would have been repeating an accident //! of where the call sat. The tree had already half-answered the question: the //! review queue this screen launches is `/review`, a top-level address served //! by [`queue`](super::queue) and taking the whole browser area, and every other //! subsystem here — [`edit`](super::edit), [`forge`](super::forge), //! [`export`](super::export) — has an address and a window of its own. //! //! **Ruled by Max: one screen at `/classifier`, five sections inside it, with //! the door in Settings.** Not five addresses with a navigation between them: //! the five are read together and the rules editor is the only one that takes //! the body over. //! //! The door is a `GET`, which is what the toolbar's own Settings, Cloud Sync //! and Help acts are, and it lands where they land: the shell runtime navigates //! and the screen is drawn inline. `draw_settings` and `draw_sync` are windows //! for the same screens and neither is reachable — the doors that set their //! flags were the shipped sidebar's and went with `49b7429`. That is a hole //! this port found rather than one it made, and adding a sixth window behind a //! flag nothing sets would have been a third copy of it. //! //! # Every control is an intent, without a single exception //! //! Unusual, and it falls out of the app rather than being a choice: all //! thirty-odd `classifier_*` methods on `BrowserState` take `&mut self`, and //! several of them start work on another thread. So there is no handle to call //! the way [`naming`](super::naming) calls `create_vfs`, and the rule about //! *what the app does about a write* never has to be applied here. //! //! # The screen is handed the rule vocabulary rather than holding it //! //! [`Classifier::testable`] is the member that shape depends on. `FIELDS`, //! `ops_for` and `op_needs_value` were in the drawing file, which put "what a //! rule can ask about a sample" one `RuleField` away from having to be added in //! two places. They are the app's now, and the description renders what it is //! given: the field select lists what came back, and the operator select lists //! what *that field* came back with. A described screen that narrowed the //! operators itself would be a second copy of `audiofiles_core::rules`. //! //! # THE FINDING, and what closed it: a repeating question repeated one field //! //! The task filed against this port named [`Field::repeats`] as the member to //! read first, on the reading that a rule's conditions are one question //! answered N times. **Counted, they are not.** A condition is a testable, a //! comparison and an operand — three questions whose answers only mean anything //! together — and [`Repeat`] holds a `Vec` where an instance is one //! value and one error. An action is two questions the same way. //! //! So the editor could not use it, and what it lost was stated here: a renderer //! had no way to know the regions were slots of one repeating question, so //! nothing could number them or draw them as a list that grows, and "at least //! one condition" was this file disabling its own last Remove. //! //! **Closed by quasicoherent `f7abbc08`** (Max chose c): [`Slot::repeating`] //! says a region's children are answers to one question, and [`Slot::removes`] //! says what takes one away. `Repeat` is untouched — it is still a repeating //! *field*, and this is a repeating *group*. Both groups here say it now, and //! the floor is [`Repeating::least`] rather than an `unless` in this file. //! //! Two consumers here, not one: conditions (three fields) and actions (two). //! Both are in the same editor, which is what made this a shape rather than a //! quirk of one screen, and what the ruling was measured against. //! //! # THE SECOND FINDING: a description cannot say "write when the control settles" //! //! The shipped thresholds and the layer weights both commit through //! `widgets::settled`, so a drag across a slider is one write rather than one //! per frame. [`Field::writes`] is the only thing the vocabulary has and it //! means every change. Three sliders here take it, so a drag is now a write per //! step: correct, and chattier than the shipped screen against a store that is //! local. //! //! Not worked around, because the workaround would be worse — a submit button //! per slider, or a debounce invented in one renderer. Filed with a count. //! //! # Two `ConfirmAction`-shaped state machines die here //! //! `pending_layer_remove` armed a layer's Remove and swapped the row for a //! Cancel/Remove pair, exactly as `trash_confirm_purge` did before //! [`trash`](super::trash) deleted it. [`Act::confirm`] again, and the field //! goes with it. That is the fifth and sixth variant of that pattern this port //! has replaced with a builder call. //! //! # Both pickers are described, and the export took the longer road //! //! Export writes a file the reader names a place for, and import reads one. //! //! **Import** takes a *path*: `classifier_import_afcl` wants one, so //! [`Outcome::Locate`]'s `Sought::File` covers it exactly. //! [`import`](fn@import) asks for an `.afcl` and [`imported`](fn@imported) //! reads it, which is the act shape [`advanced`](super::advanced)'s Import //! Theme uses. //! //! **Export** stayed an intent until quasi 0.63, and this header said it always //! would. It is a save dialog: the reader is naming a file that does not exist //! yet, and `Sought` could only ask where something already was. //! [`Outcome::File`] is not the answer either, though //! [`settings`](super::settings)'s theme export is that member's consumer here: //! the classifier's export is built on a worker thread and answers through //! `BackendEvent`, so the route has no bytes to hand over. `7fda7ae3` added `Sought::Save`, which is //! that dialog said from the description's end: [`export`](fn@export) suggests //! the name and [`exported`](fn@exported) writes to wherever the reader put it. //! The sanitising moved with it, into [`export_filename`](fn@export_filename): //! the suggestion is a description fact now, and what the reader typed into the //! sharing field is not a file name until that has been over it. //! //! [`Outcome::Locate`]: quasi_router::Outcome::Locate //! //! [`Act::confirm`]: quasi_router::Act::confirm //! [`Classifier::testable`]: super::Classifier::testable //! [`Field::writes`]: quasi_router::Field::writes //! [`Field::repeats`]: quasi_router::Field::repeats //! [`Outcome::File`]: quasi_router::Outcome::File //! [`Repeat`]: quasi_router::Repeat //! [`Repeating::least`]: quasi_router::Repeating::least //! [`Slot::removes`]: quasi_router::Slot::removes //! [`Slot::repeating`]: quasi_router::Slot::repeating use quasi_declare::declare; use quasi_router::layout::Tone; use quasi_router::{ Accepted, Action, Choice, Locating, Request, Response, RouteError, Router, Sought, Tag, }; use super::{Panels, Part, Shareable}; /// The region the whole screen answers into. const BODY: &str = "classifier-body"; /// The suffix a shared classifier is written under. const SHARED: &str = ".afcl"; /// The name a picked file comes back under. const FILE: &str = "file"; /// What removing a layer takes with it. const DROPS_LAYER: &str = "Permanently delete this layer and its imported rules? This cannot be undone."; /// Register the classifier's routes. pub fn routes(router: Router>) -> Router> { let router = router .get("/classifier", index) .post("/classifier/rules/new", new_rule) .post("/classifier/rules/apply", apply_rules) .post("/classifier/rules/starter", seed_rules) .post("/classifier/rules/{id}/enabled", enable_rule) .post("/classifier/rules/{id}/edit", edit_rule) .post("/classifier/rules/{id}/delete", delete_rule) .post("/classifier/rules/{id}/up", move_up) .post("/classifier/rules/{id}/down", move_down); let router = router .post("/classifier/draft/name", name_draft) .post("/classifier/draft/enabled", enable_draft) .post("/classifier/draft/match", match_mode) .post("/classifier/draft/conditions/add", add_condition) .post("/classifier/draft/conditions/{at}/remove", drop_condition) .post( "/classifier/draft/conditions/{at}/set/{part}", set_condition, ) .post("/classifier/draft/actions/add", add_action) .post("/classifier/draft/actions/{at}/remove", drop_action) .post("/classifier/draft/actions/{at}/set/{part}", set_action) .post("/classifier/draft/test", test_draft) .post("/classifier/draft/save", save_draft) .post("/classifier/draft/cancel", cancel_draft); let router = router .post("/classifier/autotag/suggest", suggest) .post("/classifier/autotag/review", review) .post("/classifier/autotag/reopen", reopen) .post("/classifier/head/train", train) .post("/classifier/head/clear", clear_head) .post("/classifier/policies/typing", typing_policy) .post("/classifier/policies/add", add_policy) .post("/classifier/policies/{tag}/thresholds", set_policy); let router = router .post("/classifier/clusters/k", cluster_k) .post("/classifier/clusters/find", find_clusters) .post("/classifier/clusters/{at}/play", play_cluster) .post("/classifier/clusters/{at}/name", name_cluster) .post("/classifier/clusters/{at}/tag", tag_cluster) .post("/classifier/folders/scan", scan_folders) .post("/classifier/folders/{at}/tag", name_folder) .post("/classifier/folders/{at}/apply", apply_folder) .post("/classifier/undo/{source}", undo_source); router .post("/classifier/sharing/name", name_export) .post("/classifier/sharing/include/{part}", include) .post("/classifier/sharing/export", export) .post("/classifier/sharing/exported", exported) .post("/classifier/sharing/import", import) .post("/classifier/sharing/imported", imported) .post("/classifier/layers/{id}/enabled", enable_layer) .post("/classifier/layers/{id}/weight", weigh_layer) .post("/classifier/layers/{id}/remove", remove_layer) } // --- Rules ------------------------------------------------------------------- /// `GET /classifier` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(showing(state)) } /// `POST /classifier/rules/new` fn new_rule(state: &Panels<'_>, _request: Request) -> Result { state.classifier.new_rule(); settled(state) } /// `POST /classifier/rules/apply` fn apply_rules(state: &Panels<'_>, _request: Request) -> Result { state.classifier.apply_rules(); settled(state) } /// `POST /classifier/rules/starter` fn seed_rules(state: &Panels<'_>, _request: Request) -> Result { state.classifier.seed_rules(); settled(state) } /// `POST /classifier/rules/{id}/enabled` fn enable_rule(state: &Panels<'_>, request: Request) -> Result { let id = named(&request, "id")?; state.classifier.enable_rule(id, ticked(&request, "on")); settled(state) } /// `POST /classifier/rules/{id}/edit` fn edit_rule(state: &Panels<'_>, request: Request) -> Result { state.classifier.edit_rule(named(&request, "id")?); settled(state) } /// `POST /classifier/rules/{id}/delete` fn delete_rule(state: &Panels<'_>, request: Request) -> Result { state.classifier.delete_rule(named(&request, "id")?); settled(state) } /// `POST /classifier/rules/{id}/up` fn move_up(state: &Panels<'_>, request: Request) -> Result { state.classifier.move_rule(named(&request, "id")?, true); settled(state) } /// `POST /classifier/rules/{id}/down` fn move_down(state: &Panels<'_>, request: Request) -> Result { state.classifier.move_rule(named(&request, "id")?, false); settled(state) } // --- The rule being authored ------------------------------------------------- /// `POST /classifier/draft/name` fn name_draft(state: &Panels<'_>, request: Request) -> Result { state .classifier .name_draft(request.payload.get("name").unwrap_or_default()); settled(state) } /// `POST /classifier/draft/enabled` fn enable_draft(state: &Panels<'_>, request: Request) -> Result { state.classifier.enable_draft(ticked(&request, "enabled")); settled(state) } /// `POST /classifier/draft/match` fn match_mode(state: &Panels<'_>, request: Request) -> Result { let answered = request.payload.get("match").unwrap_or_default(); state.classifier.match_all(answered != "any"); settled(state) } /// `POST /classifier/draft/conditions/add` fn add_condition(state: &Panels<'_>, _request: Request) -> Result { state.classifier.add_condition(); settled(state) } /// `POST /classifier/draft/conditions/{at}/remove` fn drop_condition(state: &Panels<'_>, request: Request) -> Result { state.classifier.drop_condition(slot(&request)?); settled(state) } /// `POST /classifier/draft/conditions/{at}/set/{part}` fn set_condition(state: &Panels<'_>, request: Request) -> Result { let at = slot(&request)?; let part = condition_part(&request)?; let name = request.captures.require("part")?; state .classifier .set_condition(at, part, request.payload.get(name).unwrap_or_default()); settled(state) } /// `POST /classifier/draft/actions/add` fn add_action(state: &Panels<'_>, _request: Request) -> Result { state.classifier.add_action(); settled(state) } /// `POST /classifier/draft/actions/{at}/remove` fn drop_action(state: &Panels<'_>, request: Request) -> Result { state.classifier.drop_action(slot(&request)?); settled(state) } /// `POST /classifier/draft/actions/{at}/set/{part}` fn set_action(state: &Panels<'_>, request: Request) -> Result { let at = slot(&request)?; let name = request.captures.require("part")?; let part = match name { "kind" => Part::Kind, "tag" => Part::Value, _ => return Err(RouteError::not_found("no such part of an action")), }; state .classifier .set_action(at, part, request.payload.get(name).unwrap_or_default()); settled(state) } /// `POST /classifier/draft/test` fn test_draft(state: &Panels<'_>, _request: Request) -> Result { state.classifier.test_draft(); settled(state) } /// `POST /classifier/draft/save` fn save_draft(state: &Panels<'_>, _request: Request) -> Result { state.classifier.save_draft(); settled(state) } /// `POST /classifier/draft/cancel` fn cancel_draft(state: &Panels<'_>, _request: Request) -> Result { state.classifier.cancel_draft(); settled(state) } // --- Auto-tagging ------------------------------------------------------------ /// `POST /classifier/autotag/suggest` fn suggest(state: &Panels<'_>, _request: Request) -> Result { state.classifier.suggest(); settled(state) } /// `POST /classifier/undo/{source}` /// /// One route for the three undo controls, because they are one question with /// three answers: which pass is being taken back. The shipped screen had the /// same string in three places. fn undo_source(state: &Panels<'_>, request: Request) -> Result { let source = request.captures.require("source")?; if !matches!(source, "ml" | "cluster" | "harvest") { return Err(RouteError::not_found("nothing applied tags that way")); } state.classifier.undo_source(source); settled(state) } /// `POST /classifier/autotag/review` fn review(state: &Panels<'_>, _request: Request) -> Result { state.classifier.review(); settled(state) } /// `POST /classifier/autotag/reopen` fn reopen(state: &Panels<'_>, _request: Request) -> Result { if state.classifier.waiting() == 0 { return Err(RouteError::not_found("nothing is waiting")); } state.classifier.reopen_review(); settled(state) } /// `POST /classifier/head/train` fn train(state: &Panels<'_>, _request: Request) -> Result { state.classifier.train(); settled(state) } /// `POST /classifier/head/clear` fn clear_head(state: &Panels<'_>, _request: Request) -> Result { state.classifier.clear_head(); settled(state) } /// `POST /classifier/policies/{tag}/thresholds` /// /// Both ends in one call. They are two controls and one row, and a tag whose /// review threshold sat above its auto threshold for the length of a round trip /// would be a state the store should never see. fn set_policy(state: &Panels<'_>, request: Request) -> Result { let tag = request.captures.require("tag")?; let current = state .classifier .policies() .into_iter() .find(|policy| policy.tag == tag) .ok_or_else(|| RouteError::not_found("no threshold for that tag"))?; let review = number(&request, "review").unwrap_or(current.review); let auto = number(&request, "auto").unwrap_or(current.auto); state.classifier.set_policy(tag, review, auto); settled(state) } /// `POST /classifier/policies/typing` fn typing_policy(state: &Panels<'_>, request: Request) -> Result { state .classifier .typing_policy(request.payload.get("tag").unwrap_or_default()); settled(state) } /// `POST /classifier/policies/add` fn add_policy(state: &Panels<'_>, _request: Request) -> Result { state.classifier.add_policy(); settled(state) } // --- Clusters and folders ---------------------------------------------------- /// `POST /classifier/clusters/k` fn cluster_k(state: &Panels<'_>, request: Request) -> Result { let asked = request .payload .get("cluster_k") .and_then(|value| value.parse().ok()) .ok_or_else(|| RouteError::not_found("that is not a number of groups"))?; state.classifier.set_cluster_k(asked); settled(state) } /// `POST /classifier/clusters/find` fn find_clusters(state: &Panels<'_>, _request: Request) -> Result { state.classifier.find_clusters(); settled(state) } /// `POST /classifier/clusters/{at}/play` fn play_cluster(state: &Panels<'_>, request: Request) -> Result { state.classifier.play_cluster(slot(&request)?); settled(state) } /// `POST /classifier/clusters/{at}/name` fn name_cluster(state: &Panels<'_>, request: Request) -> Result { let at = slot(&request)?; state .classifier .name_cluster(at, request.payload.get("name").unwrap_or_default()); settled(state) } /// `POST /classifier/clusters/{at}/tag` fn tag_cluster(state: &Panels<'_>, request: Request) -> Result { state.classifier.tag_cluster(slot(&request)?); settled(state) } /// `POST /classifier/folders/scan` fn scan_folders(state: &Panels<'_>, _request: Request) -> Result { state.classifier.scan_folders(); settled(state) } /// `POST /classifier/folders/{at}/tag` fn name_folder(state: &Panels<'_>, request: Request) -> Result { let at = slot(&request)?; state .classifier .name_folder(at, request.payload.get("tag").unwrap_or_default()); settled(state) } /// `POST /classifier/folders/{at}/apply` fn apply_folder(state: &Panels<'_>, request: Request) -> Result { state.classifier.apply_folder(slot(&request)?); settled(state) } // --- Sharing ----------------------------------------------------------------- /// `POST /classifier/sharing/name` fn name_export(state: &Panels<'_>, request: Request) -> Result { state .classifier .name_export(request.payload.get("export_name").unwrap_or_default()); settled(state) } /// `POST /classifier/sharing/include/{part}` fn include(state: &Panels<'_>, request: Request) -> Result { let name = request.captures.require("part")?; let part = match name { "exemplars" => Shareable::Exemplars, "rules" => Shareable::Rules, "thresholds" => Shareable::Thresholds, _ => return Err(RouteError::not_found("nothing of that name is shared")), }; state.classifier.include(part, ticked(&request, name)); settled(state) } /// `POST /classifier/sharing/export` /// /// Says a destination is wanted and leaves the dialog to the host. Writing the /// file is [`exported`](fn@exported)'s, on the answer. /// /// The name is a suggestion, which is the whole reason this is `Sought::Save` /// and not a folder ask with a name appended: the reader typed /// [`export_name`](fn@export_name) into the sharing section and gets to change /// it again in the dialog. fn export(state: &Panels<'_>, _request: Request) -> Result { let sharing = state.classifier.sharing(); if !sharing.worth_writing() { return Err(RouteError::not_found("there is nothing to export")); } Ok(Response::locate(Locating::new( Sought::Save { name: format!("{}{SHARED}", export_filename(&sharing.name)), accept: vec![Accepted::suffix(SHARED)], }, "Export classifier", Action::post("/classifier/sharing/exported"), FILE, ))) } /// `POST /classifier/sharing/exported` /// /// The same second guard [`imported`](fn@imported) carries, for the same reason: /// an empty answer means the reader backed out, and the address is reachable by /// typing. fn exported(state: &Panels<'_>, request: Request) -> Result { let file = request.payload.get(FILE).unwrap_or_default(); if !file.is_empty() { state.classifier.export_to(file); } settled(state) } /// A file name an export can be written under. /// /// Lifted verbatim from the deleted `ui/classifier.rs`, where it was /// `sanitize_filename`, underscore and all. It sits on the description side /// because the name is now something the description says: `Sought::Save` asks /// the host to offer it, and what the reader typed into the sharing field is not /// a file name until this has been over it. fn export_filename(name: &str) -> String { let cleaned: String = name .trim() .chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect(); if cleaned.is_empty() { "classifier".to_owned() } else { cleaned } } /// `POST /classifier/sharing/import` /// /// Says a file is wanted and leaves the picker to the host. Reading it is /// [`imported`](fn@imported)'s, on the answer. fn import(_state: &Panels<'_>, _request: Request) -> Result { Ok(Response::locate(Locating::new( Sought::File { accept: vec![Accepted::suffix(SHARED)], }, "Import classifier", Action::post("/classifier/sharing/imported"), FILE, ))) } /// `POST /classifier/sharing/imported` /// /// A reader who backed out of the picker has answered nothing: `ui::dialog` /// skips its handler on an empty result, so this is the second guard rather than /// the only one, and it is here because the address is reachable by typing. fn imported(state: &Panels<'_>, request: Request) -> Result { let file = request.payload.get(FILE).unwrap_or_default(); if !file.is_empty() { state.classifier.import(file); } settled(state) } /// `POST /classifier/layers/{id}/enabled` fn enable_layer(state: &Panels<'_>, request: Request) -> Result { let id = named(&request, "id")?; state.classifier.enable_layer(id, ticked(&request, "on")); settled(state) } /// `POST /classifier/layers/{id}/weight` fn weigh_layer(state: &Panels<'_>, request: Request) -> Result { let id = named(&request, "id")?; let weight = number(&request, "weight").ok_or_else(|| RouteError::not_found("that is not a weight"))?; state.classifier.weigh_layer(id, weight); settled(state) } /// `POST /classifier/layers/{id}/remove` fn remove_layer(state: &Panels<'_>, request: Request) -> Result { state.classifier.remove_layer(named(&request, "id")?); settled(state) } // --- Reading a request ------------------------------------------------------- /// The screen again, which is what every act here answers with. fn settled(state: &Panels<'_>) -> Result { Ok(showing(state)) } /// A captured name, refused when it is not there. fn named<'a>(request: &'a Request, capture: &str) -> Result<&'a str, RouteError> { request.captures.require(capture) } /// The slot an address names. fn slot(request: &Request) -> Result { request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("that is not a slot")) } /// Which part of a condition an address names. fn condition_part(request: &Request) -> Result { match request.captures.require("part")? { "field" => Ok(Part::Field), "op" => Ok(Part::Op), "value" => Ok(Part::Value), _ => Err(RouteError::not_found("no such part of a condition")), } } /// Whether a checkbox came back ticked. /// /// A renderer sends the name with a value when the box is on and sends nothing /// when it is off, which is the shape every `Field::writes` checkbox in this /// app already answers with. fn ticked(request: &Request, name: &str) -> bool { !request.payload.get(name).unwrap_or_default().is_empty() } /// A number a request carries, if it carries one. fn number(request: &Request, name: &str) -> Option { request.payload.get(name)?.parse().ok() } // --- The screen -------------------------------------------------------------- // --- The screen -------------------------------------------------------------- /// The screen, read and then described. fn showing(state: &Panels<'_>) -> Response { Response::from(screen(&read(state))) } /// The whole window: five sections, and the rules editor that takes one over. struct Classifying { /// What is running, while something is. /// /// What the shipped section put above everything for the same reason: the /// footer status line was hidden behind the Settings modal, so a running job /// and a failed one had to say so here. doing: Option, /// What went wrong, while nothing is running. failed: Option, /// Whether anything is running, which deadens most of this screen. busy: bool, rules: Rules, autotag: Autotag, clusters: Clusters, folders: Folders, sharing: Shared, } /// Tag Rules: the list, or the editor when one is open. struct Rules { /// The rules as they stand, while none is being authored. listing: Option, /// The rule being authored, in place of the list. editor: Option, } /// The rules as they stand. struct RuleList { /// What the last apply changed, where there has been one. last: Option, /// Whether there are no rules at all. bare: bool, each: Vec, } /// One rule in the list. struct Listed { id: String, /// What it is called, or that it is not. name: String, /// How much it tests and how much it does. meta: String, enabled: bool, /// Whether it is already the first, and whether it is already the last. first: bool, last: bool, /// What deleting it takes with it, and what it does not. confirm: String, } /// The rule being authored. struct Editor { name: String, /// Why the name was refused, while it was. /// /// Gated on a refusal rather than on emptiness: a freshly opened editor /// complaining about a name nobody has typed is scolding an empty form. problem: Option<&'static str>, enabled: &'static str, /// Whether every condition must hold, or any one of them. matching: &'static str, conditions: Vec, actions: Vec, /// What the last test counted, where there has been one. matched: Option, } /// One condition, as three questions grouped. struct Condition { /// Its position, which is its region's name and its address. at: usize, /// Every field a rule may test. fields: Vec, field: String, /// Only the comparisons this field allows, which is the whole reason the /// app hands over the vocabulary rather than the screen holding it. ops: Vec, op: String, /// The operand, only where the comparison wants one. /// /// `op_needs_value` said in the description instead of enforced while /// drawing. value: Option, } /// One action, as two questions grouped. struct Doing { /// Its position, which is its region's name and its address. at: usize, kind: String, /// The tag, except where the action is Stop. tag: Option, } /// Auto-Tagging: the pass, the review door, the head, and the thresholds. struct Autotag { /// What the last run applied, where there has been one. last: Option, /// What the review control reads while a queue is already waiting. /// /// Re-entry without a rescan: the queue outlives the screen, and a pass over /// a large library costs seconds. reopen: Option, /// What the last review said, where there has been one. reviewed: Option, head: Head, thresholds: Thresholds, } /// The optional trained head. struct Head { /// What it holds, or what training one would be worth. /// /// The size hint is only worth showing while there is no model, which is /// why one field answers both. said: Option, /// What the training control reads, which is where it says whether there is /// already a model. train: &'static str, /// Whether there is a model to clear. trained: bool, } /// The per-tag thresholds. struct Thresholds { each: Vec, /// The tag being named for a threshold it does not have yet. typing: String, } /// One tag's two thresholds. struct Threshold { tag: String, review: String, auto: String, } /// Clustering: the cold-start grouping. struct Clusters { /// How many groups to ask for. k: String, piles: Vec, } /// One group of similar samples. struct Pile { /// Its position, which is its region's name and its address. at: usize, /// How many samples are in it. said: String, name: String, /// Whether its representative is still there. /// /// The representative can be gone: deleting a sample must not take the pile /// or its name with it. playable: bool, /// Whether it has been named, which is what wakes Tag. named: bool, } /// Folder Tags: turn directories that hold samples into tags. struct Folders { /// Whether a scan happened and found nothing. /// /// Never scanned is not the same as a scan that found nothing, and the /// shipped section drew neither in that state, so this is false in both the /// unscanned case and the case with folders to show. bare: bool, each: Vec, } /// One folder that directly holds samples. struct Harvested { /// Its position, which is its region's name and its address. at: usize, /// What it is called, and how much is in it. said: String, tag: String, /// Whether a tag has been typed, which is what wakes Apply. named: bool, } /// Shared Classifiers: export, import, and the layers an import leaves. struct Shared { /// What the export file will be called. name: String, /// The three parts, each ticked or not. parts: Vec, /// Whether there is anything worth writing. worth: bool, /// What the last export said, where there has been one. exported: Option, /// What the last import said, where there has been one. imported: Option, layers: Vec, } /// One kind of thing an export may carry. struct Included { name: &'static str, label: &'static str, /// On or off, as the control carries it. value: &'static str, /// Said rather than silently contributing nothing: a part that is ticked /// and does not exist is why `worth_writing` asks about both. hint: Option<&'static str>, } /// One imported layer. struct Layer { id: String, name: String, /// What it brought with it. said: String, enabled: &'static str, /// How much it counts next to your own tagging. weight: String, } /// What the screen draws, read off the app. fn read(state: &Panels<'_>) -> Classifying { let doing = state.classifier.busy(); let busy = doing.is_some(); Classifying { // A failure is only worth reporting while nothing is running, which is // what the shipped section's `else if` said. failed: (!busy).then(|| state.classifier.failed()).flatten(), doing, busy, rules: rules_read(state), autotag: autotag_read(state), clusters: clusters_read(state), folders: folders_read(state), sharing: sharing_read(state), } } /// The rules, or the one being authored in place of them. fn rules_read(state: &Panels<'_>) -> Rules { let authoring = state.classifier.authoring(); Rules { listing: authoring.is_none().then(|| rule_list_read(state)), editor: authoring.map(|draft| editor_read(state, &draft)), } } /// The rules as they stand. fn rule_list_read(state: &Panels<'_>) -> RuleList { let all = state.classifier.rules(); RuleList { last: state .classifier .last_apply() .map(|changed| format!("Last apply: {changed} sample{} updated", plural(changed))), bare: all.is_empty(), each: all .iter() .map(|rule| { let named = if rule.name.trim().is_empty() { "(unnamed)" } else { rule.name.trim() }; Listed { id: rule.id.clone(), name: named.to_owned(), meta: format!("{} cond \u{2192} {} act", rule.conditions, rule.actions), enabled: rule.enabled, first: rule.first, last: rule.last, confirm: format!( "Delete the rule \"{named}\"? Tags it already applied stay where they are." ), } }) .collect(), } } /// The rule being authored, read off the draft. fn editor_read(state: &Panels<'_>, draft: &super::Authoring) -> Editor { let testable = state.classifier.testable(); Editor { name: draft.name.clone(), problem: (draft.refused && draft.name.trim().is_empty()) .then_some("Name the rule before saving."), enabled: switched(draft.enabled), matching: if draft.all { "all" } else { "any" }, conditions: draft .conditions .iter() .enumerate() .map(|(at, condition)| condition_read(at, condition, &testable)) .collect(), actions: draft .actions .iter() .enumerate() .map(|(at, action)| Doing { at, kind: action.kind.clone(), tag: (action.kind != "stop").then(|| action.tag.clone()), }) .collect(), matched: draft .matched .map(|matched| format!("Matches {matched} sample{}", plural(matched))), } } /// One condition, and only the comparisons its field allows. fn condition_read( at: usize, condition: &super::Testing, testable: &[super::Testable], ) -> Condition { let chosen = testable.iter().find(|field| field.value == condition.field); let ops = chosen.map(|field| field.ops.as_slice()).unwrap_or_default(); Condition { at, fields: testable .iter() .map(|field| Choice::new(field.value.clone(), field.label.clone())) .collect(), field: condition.field.clone(), ops: ops .iter() .map(|op| Choice::new(op.value.clone(), op.label.clone())) .collect(), op: condition.op.clone(), value: ops .iter() .find(|allowed| allowed.value == condition.op) .is_some_and(|allowed| allowed.takes_value) .then(|| condition.value.clone()), } } /// Auto-tagging, read off what it has already done. fn autotag_read(state: &Panels<'_>) -> Autotag { let waiting = state.classifier.waiting(); Autotag { last: state .classifier .last_suggest() .map(|applied| format!("Last run: {applied} tag{} applied", plural(applied))), reopen: (waiting > 0).then(|| format!("Reopen ({waiting})")), reviewed: state.classifier.last_review(), head: head_read(state), thresholds: Thresholds { each: state .classifier .policies() .iter() .map(|policy| Threshold { tag: policy.tag.clone(), review: format!("{:.2}", policy.review), auto: format!("{:.2}", policy.auto), }) .collect(), typing: state.classifier.policy_typing(), }, } } /// The trained head, or the case for training one. fn head_read(state: &Panels<'_>) -> Head { let head = state.classifier.head(); Head { said: match &head { Some(head) => Some(format!( "Active: {} tag{} from {} sample{}.", head.classes, plural(head.classes), head.exemplars, plural(head.exemplars), )), None => state.classifier.readiness().map(|ready| { if ready.worthwhile { format!( "No model yet. With {} tagged samples, training is recommended.", ready.tagged ) } else { format!( "No model: nearest-neighbor matching is fast enough at {} tagged sample{}.", ready.tagged, plural(ready.tagged), ) } }), }, train: if head.is_some() { "Retrain model" } else { "Train model" }, trained: head.is_some(), } } /// The clustering section, read off what it has grouped. fn clusters_read(state: &Panels<'_>) -> Clusters { Clusters { k: state.classifier.cluster_k().to_string(), piles: state .classifier .clusters() .iter() .enumerate() .map(|(at, pile)| Pile { at, said: format!("{} sample{}", pile.members, plural(pile.members)), name: pile.name.clone(), playable: pile.playable, named: !pile.name.trim().is_empty(), }) .collect(), } } /// The folder-tags section, read off whatever the last scan found. fn folders_read(state: &Panels<'_>) -> Folders { let found = state.classifier.folders(); Folders { bare: found.as_ref().is_some_and(Vec::is_empty), each: found .unwrap_or_default() .iter() .enumerate() .map(|(at, folder)| Harvested { at, said: format!( "{} ({} sample{})", folder.folder, folder.samples, plural(folder.samples) ), tag: folder.tag.clone(), named: !folder.tag.trim().is_empty(), }) .collect(), } } /// The sharing section, read off what there is to write and what came in. fn sharing_read(state: &Panels<'_>) -> Shared { let share = state.classifier.sharing(); Shared { name: share.name.clone(), parts: [ ("exemplars", "Exemplars", share.exemplars), ("rules", "Rules", share.rules), ("thresholds", "Thresholds", share.thresholds), ] .into_iter() .map(|(name, label, part)| Included { name, label, value: switched(part.wanted), hint: (!part.available).then_some("Nothing of this kind yet."), }) .collect(), worth: share.worth_writing(), exported: share.exported.clone(), imported: share.imported.clone(), layers: state .classifier .layers() .iter() .map(|layer| Layer { id: layer.id.clone(), name: layer.name.clone(), said: format!( "{} ex \u{b7} {} rule{}", layer.exemplars, layer.rules, plural(layer.rules) ), enabled: switched(layer.enabled), weight: format!("{:.2}", layer.weight), }) .collect(), } } declare! { /// The whole window. /// /// One screen and five sections, which is Max's ruling: the five are read /// together and the rules editor is the only one that takes the body over. shape screen(classify: &Classifying) -> Screen; screen sidebar_content "Tag classifier" { region BODY as Pane { page "Tag classifier"; for doing in classify.doing.iter() { underway doing; } for failed in classify.failed.iter() { banner Tone::Warning failed; } extend rules(&classify.rules, classify.busy); extend autotag(&classify.autotag, classify.busy); extend clusters(&classify.clusters, classify.busy); extend folders(&classify.folders); extend sharing(&classify.sharing, classify.busy); } } } declare! { /// Tag Rules: the list, or the editor when one is open. shape rules(rules: &Rules, busy: bool) -> Vec; section "Tag Rules"; text "Deterministic rules that auto-apply tags by sample metadata and audio \ features. Rules never remove tags you added by hand."; for listing in rules.listing.iter() { extend rule_list(listing, busy); } for editor in rules.editor.iter() { extend rule_editor(editor); } } declare! { /// The rules as they stand. shape rule_list(listing: &RuleList, busy: bool) -> Vec; act "New rule" to post "/classifier/rules/new" { disabled when busy; } text "Re-evaluate every rule across the whole library."; act "Apply rules now" to post "/classifier/rules/apply" { disabled when busy; } text "Starter rules cover the common instrument and format words (Kick.wav \ becomes instrument.drum.kick). They arrive disabled: review them, \ then enable the ones you want."; act "Add starter rules" to post "/classifier/rules/starter" { disabled when busy; } for last in listing.last.iter() { text last; } empty "No rules yet. New rules start empty; you decide what gets tagged." when listing.bare; list { for rule in listing.each.iter() { row &rule.name { meta &rule.meta; toggling rule.enabled Action::post("/classifier/rules/{rule.id}/enabled"); token Tag::badge("off") unless rule.enabled; act "Edit" to post "/classifier/rules/{rule.id}/edit"; act "Earlier" to post "/classifier/rules/{rule.id}/up" { disabled when rule.first; } act "Later" to post "/classifier/rules/{rule.id}/down" { disabled when rule.last; } act "Delete" to post "/classifier/rules/{rule.id}/delete" { tone Danger; confirm &rule.confirm; } } } } unless listing.bare; } declare! { /// The rule being authored, in place of the list. shape rule_editor(editor: &Editor) -> Vec; field Text "name" "Name" { required; placeholder "Kick drums"; value &editor.name; writes Action::post("/classifier/draft/name"); for &problem in editor.problem.iter() { error problem; } } field Checkbox "enabled" "Enabled" { value editor.enabled; writes Action::post("/classifier/draft/enabled"); } field Radio "match" "Match" { option Choice::new("all", "All of these conditions"); option Choice::new("any", "Any of these conditions"); value editor.matching; writes Action::post("/classifier/draft/match"); } // The conditions and the actions, each as slots of one repeating question. // quasicoherent `f7abbc08`: the regions were always here and nothing said // they were slots, so no renderer could number them and "at least one // condition" was this file disabling its own last Remove. // // `least 1` is that rule, said once. The actions say no floor at all, which // is the honest answer for them: a rule with no actions is describable and // the editor has never stopped anyone writing one. section "When"; region "conditions" as Group { repeats "Condition" adds "Add condition" to post "/classifier/draft/conditions/add" { least 1; } for held in editor.conditions.iter() { include condition(held); } } section "Then"; region "actions" as Group { repeats "Action" adds "Add action" to post "/classifier/draft/actions/add"; for held in editor.actions.iter() { include doing(held); } } text "Count how many samples match, without writing."; act "Test" to post "/classifier/draft/test"; for matched in editor.matched.iter() { text matched; } act "Save" to post "/classifier/draft/save"; act "Cancel" to post "/classifier/draft/cancel" { key "esc"; } } declare! { /// One condition, as three questions grouped: one slot of a repeating /// question. /// /// See the module header's first finding and what closed it. This region /// says what takes it away and its parent says how few may be left standing, /// so the floor is the description's rather than this file's. shape condition(condition: &Condition) -> Node; region "condition-{condition.at}" as Group { field Select "field" "Test" { options condition.fields.clone(); value &condition.field; writes Action::post("/classifier/draft/conditions/{condition.at}/set/field"); } field Select "op" "Comparison" { options condition.ops.clone(); value &condition.op; writes Action::post("/classifier/draft/conditions/{condition.at}/set/op"); } for operand in condition.value.iter() { field Text "value" "Value" { value operand; writes Action::post("/classifier/draft/conditions/{condition.at}/set/value"); } } // The last condition cannot be removed. This file used to say so by // disabling its own button; the parent's `least` says it now, and every // renderer draws it dead rather than hidden. removes "Remove condition" to post "/classifier/draft/conditions/{condition.at}/remove"; } } declare! { /// One action, as two questions grouped: one slot of a repeating question. shape doing(does: &Doing) -> Node; region "action-{does.at}" as Group { field Select "kind" "Do" { option Choice::new("add", "Add tag"); option Choice::new("remove", "Remove tag"); option Choice::new("stop", "Stop"); value &does.kind; writes Action::post("/classifier/draft/actions/{does.at}/set/kind"); } for tag in does.tag.iter() { field Text "tag" "Tag" { placeholder "instrument.drum.kick"; value tag; writes Action::post("/classifier/draft/actions/{does.at}/set/tag"); } } removes "Remove action" to post "/classifier/draft/actions/{does.at}/remove"; } } declare! { /// Auto-Tagging: the pass, the review door, the head, and the thresholds. shape autotag(auto: &Autotag, busy: bool) -> Vec; section "Auto-Tagging"; text "Suggests tags from samples that look like ones you've already tagged. \ Tags above a per-tag threshold are applied automatically."; act "Suggest tags across library" to post "/classifier/autotag/suggest" { disabled when busy; } act "Undo auto-tagging" to post "/classifier/undo/ml" { confirm "Remove every tag auto-tagging applied?"; disabled when busy; } for last in auto.last.iter() { text last; } section "Review suggestions"; text "Collects what auto-tagging would apply, without applying any of it, \ then opens a screen to accept it a tag at a time."; act "Review suggestions" to post "/classifier/autotag/review" { disabled when busy; } for reopen in auto.reopen.iter() { act reopen to post "/classifier/autotag/reopen" { disabled when busy; } } for reviewed in auto.reviewed.iter() { text reviewed; } extend trained(&auto.head, busy); extend thresholds(&auto.thresholds); } declare! { /// The optional trained head. shape trained(head: &Head, busy: bool) -> Vec; section "Trained model"; text "For large libraries, distil your tagged samples into a compact model \ so auto-tagging runs much faster. Optional: auto-tagging works \ without it."; for said in head.said.iter() { text said; } act head.train to post "/classifier/head/train" { disabled when busy; } act "Clear model" to post "/classifier/head/clear" when head.trained { confirm "Remove the model? Auto-tagging reverts to nearest-neighbor \ matching."; disabled when busy; } } declare! { /// The per-tag thresholds. shape thresholds(thresholds: &Thresholds) -> Vec; section "Per-tag thresholds"; text "review = surface for review \u{b7} auto = apply automatically"; for held in thresholds.each.iter() { include threshold(held); } field Text "tag" "Tag to configure" { value &thresholds.typing; writes Action::post("/classifier/policies/typing"); } act "Add threshold" to post "/classifier/policies/add"; } declare! { /// One tag's two thresholds. /// /// `Range` and not a validated number: the two ends *are* the question here, /// since 0 is never and 1 is only-on-certainty and a typed 0.72 says nothing /// without both of them on screen. shape threshold(policy: &Threshold) -> Node; region "policy-{policy.tag}" as Group { text &policy.tag; field Range "review" "review" { within "0" "1"; step "0.01"; value &policy.review; writes Action::post("/classifier/policies/{policy.tag}/thresholds") .with("review", &policy.review); } field Range "auto" "auto" { within "0" "1"; step "0.01"; value &policy.auto; writes Action::post("/classifier/policies/{policy.tag}/thresholds") .with("auto", &policy.auto); } } } declare! { /// Clustering: the cold-start grouping. shape clusters(clusters: &Clusters, busy: bool) -> Vec; section "Clustering"; text "Groups similar samples so you can name them and seed your first tags. \ Useful when nothing is tagged yet."; // A whole number between two ends: 2 is the fewest groups worth having and // 24 is where the grouping stops telling you anything. field Range "cluster_k" "Groups" { within "2" "24"; step "1"; value &clusters.k; writes Action::post("/classifier/clusters/k"); } act "Find clusters" to post "/classifier/clusters/find" { disabled when busy; } for held in clusters.piles.iter() { include pile(held); } act "Remove all cluster tags" to post "/classifier/undo/cluster" unless clusters.piles.is_empty() { confirm "Remove every tag applied from clustering?"; } } declare! { /// One group of similar samples. shape pile(pile: &Pile) -> Node; region "cluster-{pile.at}" as Group { text &pile.said; field Text "name" "Name this group" { value &pile.name; writes Action::post("/classifier/clusters/{pile.at}/name"); } act "Play" to post "/classifier/clusters/{pile.at}/play" { disabled unless pile.playable; } act "Tag" to post "/classifier/clusters/{pile.at}/tag" { disabled unless pile.named; } } } declare! { /// Folder Tags: turn directories that hold samples into tags. shape folders(folders: &Folders) -> Vec; section "Folder Tags"; text "Turns folders that directly contain samples into tags. Useful when \ your library is already organized into folders."; act "Scan folders" to post "/classifier/folders/scan"; act "Undo folder tags" to post "/classifier/undo/harvest" { confirm "Remove every tag applied from folders?"; } empty "No folders with samples found." when folders.bare; for held in folders.each.iter() { include harvested(held); } } declare! { /// One folder that directly holds samples. shape harvested(folder: &Harvested) -> Node; region "folder-{folder.at}" as Group { text &folder.said; field Text "tag" "Tag" { value &folder.tag; writes Action::post("/classifier/folders/{folder.at}/tag"); } act "Apply" to post "/classifier/folders/{folder.at}/apply" { disabled unless folder.named; } } } declare! { /// Shared Classifiers: export, import, and the layers an import leaves. shape sharing(shared: &Shared, busy: bool) -> Vec; section "Shared Classifiers (.afcl)"; text "Share your tagging as a portable .afcl file. It carries your feature \ vectors, tags, rules, and thresholds, but never any audio. Imported \ files become removable layers that sit below your own tagging."; field Text "export_name" "Name" { placeholder "My classifier"; value &shared.name; writes Action::post("/classifier/sharing/name"); } for part in shared.parts.iter() { field Checkbox part.name part.label { value part.value; writes Action::post("/classifier/sharing/include/{part.name}"); for &said in part.hint.iter() { hint said; } } } act "Export to file..." to post "/classifier/sharing/export" { disabled when busy or not shared.worth; } text "Nothing to export yet. Tag samples or add rules first." unless shared.worth; for exported in shared.exported.iter() { text exported; } act "Import .afcl file..." to post "/classifier/sharing/import" { disabled when busy; } for imported in shared.imported.iter() { text imported; } section "Imported layers" unless shared.layers.is_empty(); text "Imported rules arrive disabled. Review them in Tag Rules above before \ enabling. Weight sets how much a layer counts next to your own \ tagging." unless shared.layers.is_empty(); for held in shared.layers.iter() { include layer(held); } } declare! { /// One imported layer. shape layer(layer: &Layer) -> Node; region "layer-{layer.id}" as Group { text &layer.name; text &layer.said; field Checkbox "on" "Use when auto-tagging" { value layer.enabled; writes Action::post("/classifier/layers/{layer.id}/enabled"); } field Range "weight" "Weight" { within "0" "1"; step "0.01"; hint "Your own tags always count 1.0; lower means weaker."; value &layer.weight; writes Action::post("/classifier/layers/{layer.id}/weight"); } act "Remove layer" to post "/classifier/layers/{layer.id}/remove" { tone Danger; confirm DROPS_LAYER; } } } /// A switch as the control that sets it carries it. const fn switched(on: bool) -> &'static str { if on { "on" } else { "" } } /// The plural `s`, or nothing. const fn plural(count: usize) -> &'static str { if count == 1 { "" } else { "s" } }