Skip to main content

max / audiofiles

Describe the classifier, and delete ui/classifier.rs The largest hole the settings flip left, and the only one that lost a subsystem rather than a section: 1,432 lines that still compiled with nothing calling them. It is described now, at `/classifier`, and the file is gone. WHAT SCREEN IT IS was the first work, not the routes, and the task said so. It arrived as a Settings section because `draw_settings_panel` called it between Display and License. The tree had half-answered already: the review queue this thing launches is `/review`, a top-level address served by `quasi::queue`, and edit, forge and export each have an address of their own. Max ruled it 2026-08-25: one screen at `/classifier`, five sections, door in Settings. The door is a GET, matching the toolbar's Settings, Cloud Sync and Help, and it lands where those land. Which turned up a hole this port did not make: `draw_settings` and `draw_sync` are windows nothing can open, because the doors that set their flags were the shipped sidebar's and went with `49b7429`. Adding a sixth window behind a flag nothing sets would have been a third copy of that, so there is one address and no flag. Filed separately. THE SCREEN IS HANDED THE RULE VOCABULARY. `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 needing to be added in two places. The app answers `testable()` now and the description renders it, so the operator select narrows to what the chosen field allows without the screen knowing why. The wire names are held against serde's own spelling by a test rather than by anyone remembering. FINDING 1: a repeating question repeats one field, and a condition is three. The task named `Field::repeats` as the member to read first. Counted, it does not fit: a condition is a testable, a comparison and an operand, and `Instance` is one value and one error. An action is two questions the same way. So each is a region holding its own fields, which says everything honestly and loses the one thing the member would have given -- nothing can know the regions are slots of one question. Two consumers in one editor. FINDING 2: a description cannot say "write when the control settles". The shipped thresholds and layer weights commit through `widgets::settled`, so a drag is one write. `Field::changes` means every change, and three sliders here take it. Both filed on quasicoherent. `Curve` is not re-exported from quasi-router, which makes `Field::curve` uncallable from outside; `Field::step` covers what this screen needed and that is filed too. `pending_layer_remove` joins `trash_confirm_purge`: an arm-then-confirm state machine replaced by `Act::confirm`, the fifth of that pattern this port has deleted. The classifier no longer blocks `cefe4159`; two sections of the five still do.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 21:58 UTC
Signed with PGP, not checked
Commit: 353f70bd222cd0f4b9eea41457e1d3abc76d73d2
Parent: a451e38
8 files changed, +1568 insertions, -518 deletions
@@ -28,6 +28,7 @@
28 28 //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame |
29 29 //! | [`Storage`] | [`storage`] | an [`Intent`], applied after the frame |
30 30 //! | [`Trash`] | [`trash`] | an [`Intent`], applied after the frame |
31 + //! | [`Classifier`] | [`classifier`] | an [`Intent`], applied after the frame |
31 32 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
32 33 //!
33 34 //! The themes are the settled rule from goingson's settings port applied first
@@ -123,6 +124,7 @@
123 124 #![allow(clippy::needless_pass_by_value)]
124 125
125 126 pub mod bulk;
127 + pub mod classifier;
126 128 pub mod detail;
127 129 pub mod edit;
128 130 pub mod export;
@@ -923,6 +925,92 @@
923 925 LocateLooseFiles,
924 926 /// Bring a tombstoned sample back.
925 927 RestoreSample(String),
928 + /// Begin authoring a rule.
929 + NewRule,
930 + /// Re-evaluate every rule across the library.
931 + ApplyRules,
932 + /// Add the filename starter rules, disabled.
933 + SeedRules,
934 + /// Turn one rule on or off, by id.
935 + EnableRule(String, bool),
936 + /// Open one rule in the editor, by id.
937 + EditRule(String),
938 + /// Delete one rule, by id.
939 + DeleteRule(String),
940 + /// Move one rule earlier (`true`) or later in evaluation order.
941 + MoveRule(String, bool),
942 + /// Name the rule being authored.
943 + NameDraft(String),
944 + /// Say whether it is enabled.
945 + EnableDraft(bool),
946 + /// Say whether every condition must hold, or any.
947 + MatchAll(bool),
948 + /// Add a condition to the draft.
949 + AddCondition,
950 + /// Take one away.
951 + DropCondition(usize),
952 + /// Set one part of one condition.
953 + SetCondition(usize, Part, String),
954 + /// Add an action to the draft.
955 + AddAction,
956 + /// Take one away.
957 + DropAction(usize),
958 + /// Set one part of one action.
959 + SetAction(usize, Part, String),
960 + /// Count what the draft would match.
961 + TestDraft,
962 + /// Save it.
963 + SaveDraft,
964 + /// Throw it away.
965 + CancelDraft,
966 + /// Suggest tags across the library.
967 + SuggestLibrary,
968 + /// Take back every tag one pass applied.
969 + UndoTagSource(String),
970 + /// Collect what auto-tagging would apply, writing nothing.
971 + ReviewLibrary,
972 + /// Go back to the queue the last collection left.
973 + ReopenReview,
974 + /// Distil the tagged library into a head.
975 + TrainHead,
976 + /// Throw the head away.
977 + ClearHead,
978 + /// Set one tag's two thresholds.
979 + SetPolicy(String, f64, f64),
980 + /// Remember what is being typed into the add-a-threshold box.
981 + TypingPolicy(String),
982 + /// Configure the tag that was typed.
983 + AddPolicy,
984 + /// Ask for this many groups next time.
985 + ClusterK(u32),
986 + /// Group the library.
987 + FindClusters,
988 + /// Preview a group's representative sample.
989 + PlayCluster(usize),
990 + /// Name a group.
991 + NameCluster(usize, String),
992 + /// Tag every sample in a group with its name.
993 + TagCluster(usize),
994 + /// Look for folders that directly hold samples.
995 + ScanFolders,
996 + /// Set the tag a folder would apply.
997 + NameFolder(usize, String),
998 + /// Apply it.
999 + ApplyFolder(usize),
1000 + /// Name the file an export would write.
1001 + NameExport(String),
1002 + /// Say whether an export carries one part.
1003 + Include(Shareable, bool),
1004 + /// Write the export.
1005 + ExportClassifier,
1006 + /// Read someone else's.
1007 + ImportClassifier,
1008 + /// Turn a layer on or off.
1009 + EnableLayer(String, bool),
1010 + /// Say how much a layer counts.
1011 + WeighLayer(String, f64),
1012 + /// Delete a layer and its imported rules.
1013 + RemoveLayer(String),
926 1014 /// Remove a tombstoned sample now, skipping the retention window.
927 1015 PurgeSample(String),
928 1016 /// Open this library.
@@ -4748,6 +4836,1086 @@
4748 4836 /// of `Backend`, and this side reads the key it reads.
4749 4837 const RETAIN_DAYS: i64 = 30;
4750 4838
4839 + /// The tag classifier, as much of it as its own screen needs.
4840 + ///
4841 + /// The fourteenth narrow trait and much the widest, because the subsystem is:
4842 + /// a rules editor, an auto-tagging pass with per-tag thresholds, an optional
4843 + /// trained head, a cluster browser, a folder-label pass and an import/export of
4844 + /// the whole thing. Every write is an intent, without exception — every
4845 + /// `classifier_*` method on `BrowserState` takes `&mut self`.
4846 + ///
4847 + /// # It says what a rule may test, rather than the description knowing
4848 + ///
4849 + /// [`testable`](Self::testable) is the unusual member. The obvious shape would
4850 + /// have been for the description to hold the field list and the operators each
4851 + /// field allows, the way `ui/classifier.rs` held `FIELDS`, `ops_for` and
4852 + /// `op_needs_value`. That would put "what a rule can ask about a sample" in the
4853 + /// description layer, where it is not: it is `audiofiles_core::rules`, and a
4854 + /// new `RuleField` would then have to be added in two places.
4855 + ///
4856 + /// So the app hands over the vocabulary and the description renders it. The
4857 + /// per-field operator set stops being a rule the screen re-implements and
4858 + /// becomes a described fact, which is also what lets the operator select narrow
4859 + /// when the field changes without the description knowing why.
4860 + pub trait Classifier {
4861 + /// What a job is doing, while one is running.
4862 + fn busy(&self) -> Option<String>;
4863 +
4864 + /// What the last job said went wrong.
4865 + fn failed(&self) -> Option<String>;
4866 +
4867 + /// Every rule, in evaluation order.
4868 + fn rules(&self) -> Vec<Ruling>;
4869 +
4870 + /// What a rule may test, and how.
4871 + fn testable(&self) -> Vec<Testable>;
4872 +
4873 + /// The rule being authored, if one is.
4874 + fn authoring(&self) -> Option<Authoring>;
4875 +
4876 + /// How many samples the last whole-library rule pass changed.
4877 + fn last_apply(&self) -> Option<usize>;
4878 +
4879 + /// How many tags the last auto-tagging pass applied.
4880 + fn last_suggest(&self) -> Option<usize>;
4881 +
4882 + /// What the last accepted review said.
4883 + fn last_review(&self) -> Option<String>;
4884 +
4885 + /// How many candidates are waiting in the review queue.
4886 + fn waiting(&self) -> usize;
4887 +
4888 + /// The trained head, if there is one.
4889 + fn head(&self) -> Option<Head>;
4890 +
4891 + /// Whether training is worth it yet, when there is no head.
4892 + fn readiness(&self) -> Option<Readiness>;
4893 +
4894 + /// The per-tag thresholds.
4895 + fn policies(&self) -> Vec<Policy>;
4896 +
4897 + /// The tag being typed into the add-a-threshold box.
4898 + fn policy_typing(&self) -> String;
4899 +
4900 + /// How many groups the next clustering run is asked for.
4901 + fn cluster_k(&self) -> u32;
4902 +
4903 + /// The groups the last run left, with the names typed against them.
4904 + fn clusters(&self) -> Vec<Pile>;
4905 +
4906 + /// The folders that directly hold samples, once they have been scanned.
4907 + ///
4908 + /// `None` before a scan, which is different from a scan that found none.
4909 + fn folders(&self) -> Option<Vec<Harvested>>;
4910 +
4911 + /// The export form as it stands.
4912 + fn sharing(&self) -> Sharing;
4913 +
4914 + /// The layers imported from `.afcl` files.
4915 + fn layers(&self) -> Vec<Layer>;
4916 +
4917 + /// Begin a new rule.
4918 + fn new_rule(&self);
4919 +
4920 + /// Re-evaluate every rule across the library.
4921 + fn apply_rules(&self);
4922 +
4923 + /// Add the filename starter rules, disabled.
4924 + fn seed_rules(&self);
4925 +
4926 + /// Turn one rule on or off.
4927 + fn enable_rule(&self, id: &str, on: bool);
4928 +
4929 + /// Open one rule in the editor.
4930 + fn edit_rule(&self, id: &str);
4931 +
4932 + /// Delete one rule.
4933 + fn delete_rule(&self, id: &str);
4934 +
4935 + /// Move one rule earlier or later in evaluation order.
4936 + fn move_rule(&self, id: &str, up: bool);
4937 +
4938 + /// Name the rule being authored.
4939 + fn name_draft(&self, name: &str);
4940 +
4941 + /// Say whether the rule being authored is enabled.
4942 + fn enable_draft(&self, on: bool);
4943 +
4944 + /// Say whether every condition must hold, or any.
4945 + fn match_all(&self, all: bool);
4946 +
4947 + /// Add a condition.
4948 + fn add_condition(&self);
4949 +
4950 + /// Take one away.
4951 + fn drop_condition(&self, at: usize);
4952 +
4953 + /// Set one part of one condition.
4954 + fn set_condition(&self, at: usize, part: Part, value: &str);
4955 +
4956 + /// Add an action.
4957 + fn add_action(&self);
4958 +
4959 + /// Take one away.
4960 + fn drop_action(&self, at: usize);
4961 +
4962 + /// Set one part of one action.
4963 + fn set_action(&self, at: usize, part: Part, value: &str);
4964 +
4965 + /// Count what the draft would match, without writing anything.
4966 + fn test_draft(&self);
4967 +
4968 + /// Save it.
4969 + fn save_draft(&self);
4970 +
4971 + /// Throw it away.
4972 + fn cancel_draft(&self);
4973 +
4974 + /// Suggest tags across the library and apply the confident ones.
4975 + fn suggest(&self);
4976 +
4977 + /// Take back every tag a given pass applied.
4978 + fn undo_source(&self, source: &str);
4979 +
4980 + /// Collect what auto-tagging would apply, writing nothing.
4981 + fn review(&self);
4982 +
4983 + /// Go back to the queue the last collection left.
4984 + fn reopen_review(&self);
4985 +
4986 + /// Distil the tagged library into a head.
4987 + fn train(&self);
4988 +
4989 + /// Throw the head away.
4990 + fn clear_head(&self);
4991 +
4992 + /// Set one tag's two thresholds.
4993 + fn set_policy(&self, tag: &str, review: f64, auto: f64);
4994 +
4995 + /// Remember what is being typed into the add-a-threshold box.
4996 + fn typing_policy(&self, tag: &str);
4997 +
4998 + /// Configure the tag that was typed.
4999 + fn add_policy(&self);
5000 +
5001 + /// Ask for this many groups next time.
5002 + fn set_cluster_k(&self, k: u32);
5003 +
5004 + /// Group the library.
5005 + fn find_clusters(&self);
5006 +
5007 + /// Preview a group's representative sample.
5008 + fn play_cluster(&self, at: usize);
5009 +
5010 + /// Name a group.
5011 + fn name_cluster(&self, at: usize, name: &str);
5012 +
5013 + /// Tag every sample in a group with its name.
5014 + fn tag_cluster(&self, at: usize);
5015 +
5016 + /// Look for folders that directly hold samples.
5017 + fn scan_folders(&self);
5018 +
5019 + /// Set the tag a folder would apply.
5020 + fn name_folder(&self, at: usize, tag: &str);
5021 +
5022 + /// Apply it.
5023 + fn apply_folder(&self, at: usize);
5024 +
5025 + /// Name the file an export would write.
5026 + fn name_export(&self, name: &str);
5027 +
5028 + /// Say whether an export carries this part.
5029 + fn include(&self, part: Shareable, on: bool);
5030 +
5031 + /// Write the export.
5032 + fn export(&self);
5033 +
5034 + /// Read someone else's.
5035 + fn import(&self);
5036 +
5037 + /// Turn a layer on or off.
5038 + fn enable_layer(&self, id: &str, on: bool);
5039 +
5040 + /// Say how much a layer counts.
5041 + fn weigh_layer(&self, id: &str, weight: f64);
5042 +
5043 + /// Delete a layer and its imported rules.
5044 + fn remove_layer(&self, id: &str);
5045 + }
5046 +
5047 + /// One rule, as the list names it.
5048 + #[derive(Debug, Clone, PartialEq, Eq)]
5049 + pub struct Ruling {
5050 + /// Its stored id, which is what every act carries.
5051 + pub id: String,
5052 + /// What it is called, empty when it was never named.
5053 + pub name: String,
5054 + /// Whether it runs.
5055 + pub enabled: bool,
5056 + /// How many conditions it tests.
5057 + pub conditions: usize,
5058 + /// How many actions it takes.
5059 + pub actions: usize,
5060 + /// Whether it is first in evaluation order.
5061 + pub first: bool,
5062 + /// Whether it is last.
5063 + pub last: bool,
5064 + }
5065 +
5066 + /// Something a rule can test, and the comparisons it allows.
5067 + #[derive(Debug, Clone, PartialEq, Eq)]
5068 + pub struct Testable {
5069 + /// The wire name, which is what a condition carries.
5070 + pub value: String,
5071 + /// What it is called.
5072 + pub label: String,
5073 + /// What may be asked of it.
5074 + pub ops: Vec<Comparison>,
5075 + }
5076 +
5077 + /// One comparison, and whether it wants an operand.
5078 + #[derive(Debug, Clone, PartialEq, Eq)]
5079 + pub struct Comparison {
5080 + /// The wire name.
5081 + pub value: String,
5082 + /// What it is called.
5083 + pub label: String,
5084 + /// Whether a value is compared against, or the operator is the whole test.
5085 + pub takes_value: bool,
5086 + }
5087 +
5088 + /// The rule being authored.
5089 + #[derive(Debug, Clone, PartialEq, Eq)]
5090 + pub struct Authoring {
5091 + /// What it is being called.
5092 + pub name: String,
5093 + /// Whether it will run once saved.
5094 + pub enabled: bool,
5095 + /// Whether every condition must hold, or any.
5096 + pub all: bool,
5097 + /// The conditions, in order.
5098 + pub conditions: Vec<Testing>,
5099 + /// The actions, in order.
5100 + pub actions: Vec<Doing>,
5101 + /// What a dry run counted, if one has been asked for.
5102 + pub matched: Option<usize>,
5103 + /// Whether a save has been refused, which is what gates the name's error.
5104 + pub refused: bool,
5105 + }
5106 +
5107 + /// One condition of the rule being authored.
5108 + #[derive(Debug, Clone, PartialEq, Eq)]
5109 + pub struct Testing {
5110 + /// Which testable, by wire name.
5111 + pub field: String,
5112 + /// Which comparison, by wire name.
5113 + pub op: String,
5114 + /// What it is compared against.
5115 + pub value: String,
5116 + }
5117 +
5118 + /// One action of the rule being authored.
5119 + #[derive(Debug, Clone, PartialEq, Eq)]
5120 + pub struct Doing {
5121 + /// `add`, `remove` or `stop`.
5122 + pub kind: String,
5123 + /// The tag, empty for `stop`.
5124 + pub tag: String,
5125 + }
5126 +
5127 + /// Which part of a condition or an action is being set.
5128 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5129 + pub enum Part {
5130 + /// A condition's testable.
5131 + Field,
5132 + /// A condition's comparison.
5133 + Op,
5134 + /// A condition's operand, or an action's tag.
5135 + Value,
5136 + /// An action's kind.
5137 + Kind,
5138 + }
5139 +
5140 + /// The trained head that is in place.
5141 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5142 + pub struct Head {
5143 + /// How many tags it knows.
5144 + pub classes: usize,
5145 + /// How many samples it was distilled from.
5146 + pub exemplars: usize,
5147 + }
5148 +
5149 + /// Whether training is worth doing yet.
5150 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5151 + pub struct Readiness {
5152 + /// How many samples carry a tag.
5153 + pub tagged: usize,
5154 + /// Whether that is enough for a head to pay for itself.
5155 + pub worthwhile: bool,
5156 + }
5157 +
5158 + /// One tag's two thresholds.
5159 + #[derive(Debug, Clone, PartialEq)]
5160 + pub struct Policy {
5161 + /// The tag.
5162 + pub tag: String,
5163 + /// Above this, a suggestion is surfaced for review.
5164 + pub review: f64,
5165 + /// Above this, it is applied without asking.
5166 + pub auto: f64,
5167 + }
5168 +
5169 + /// One group the clustering pass left.
5170 + #[derive(Debug, Clone, PartialEq, Eq)]
5171 + pub struct Pile {
5172 + /// How many samples are in it.
5173 + pub members: usize,
5174 + /// The name typed against it, empty when it has none.
5175 + pub name: String,
5176 + /// Whether it has a representative sample left to preview.
5177 + ///
5178 + /// A pile outlives the sample at its centre: deleting that sample must not
5179 + /// take the pile or its name with it.
5180 + pub playable: bool,
5181 + }
5182 +
5183 + /// One folder that directly holds samples.
5184 + #[derive(Debug, Clone, PartialEq, Eq)]
5185 + pub struct Harvested {
5186 + /// The directory's own name.
5187 + pub folder: String,
5188 + /// How many samples sit directly in it.
5189 + pub samples: usize,
5190 + /// The tag it would apply, as suggested or as edited.
5191 + pub tag: String,
5192 + }
5193 +
5194 + /// The export form as it stands.
5195 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
5196 + pub struct Sharing {
5197 + /// What the file will be called.
5198 + pub name: String,
5199 + /// Whether the feature vectors go in, and whether there are any.
5200 + pub exemplars: Included,
5201 + /// Whether the rules go in.
5202 + pub rules: Included,
5203 + /// Whether the thresholds go in.
5204 + pub thresholds: Included,
5205 + /// What the last export said.
5206 + pub exported: Option<String>,
5207 + /// What the last import said.
5208 + pub imported: Option<String>,
5209 + }
5210 +
5211 + impl Sharing {
5212 + /// Whether an export would carry anything at all.
5213 + ///
5214 + /// Asked of the three parts rather than of the toggles: a part that is
5215 + /// wanted and does not exist contributes nothing, which is the distinction
5216 + /// `export_has_content` drew and the reason [`Included`] has two fields.
5217 + #[must_use]
5218 + pub fn worth_writing(&self) -> bool {
5219 + [self.exemplars, self.rules, self.thresholds]
5220 + .iter()
5221 + .any(Included::counts)
5222 + }
5223 + }
5224 +
5225 + /// Whether a part of an export is wanted, and whether it exists.
5226 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5227 + pub struct Included {
5228 + /// Whether the reader asked for it.
5229 + pub wanted: bool,
Lines truncated
@@ -33,10 +33,10 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge,
37 - FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromStorage,
38 - FromSyncManager, FromTrash, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice,
39 - Unconfigured,
36 + FromBackend, FromBar, FromBulk, FromClassifier, FromContents, FromEditor, FromExport,
37 + FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue,
38 + FromSelection, FromStorage, FromSyncManager, FromTrash, FromWindow, Intent, Panels, Setting,
39 + Sync, ThemeChoice, Unconfigured,
40 40 };
41 41 use crate::state::BrowserState;
42 42 use crate::ui::theme;
@@ -1537,6 +1537,168 @@
1537 1537 // Both refresh the trash list themselves, so nothing here has to.
1538 1538 Intent::RestoreSample(hash) => state.undelete_sample(&hash),
1539 1539 Intent::PurgeSample(hash) => state.purge_sample(&hash),
1540 + // The classifier. Every arm is one `classifier_*` method, because
1541 + // every one of them is `&mut self` and several start work on
1542 + // another thread: there is no handle a route could have called.
1543 + Intent::NewRule => state.classifier_new_draft(),
1544 + Intent::ApplyRules => state.classifier_apply_all(),
1545 + Intent::SeedRules => state.classifier_seed_starter_rules(),
1546 + Intent::EnableRule(id, on) => state.classifier_toggle_rule(&id, on),
1547 + Intent::EditRule(id) => {
1548 + if let Some(rule) = state
1549 + .classifier
1550 + .rules
1551 + .iter()
1552 + .find(|rule| rule.id == id)
1553 + .cloned()
1554 + {
1555 + state.classifier_edit_rule(&rule);
1556 + }
1557 + }
1558 + Intent::DeleteRule(id) => state.classifier_delete_rule(&id),
1559 + // The store orders by priority and the mover takes a position, so
1560 + // the id is resolved here rather than in the route: a described
1561 + // screen naming a row by its id should not have to know that the
1562 + // app moves it by index.
1563 + Intent::MoveRule(id, up) => {
1564 + if let Some(at) = state.classifier.rules.iter().position(|rule| rule.id == id) {
1565 + state.classifier_move_rule(at, up);
1566 + }
1567 + }
1568 + Intent::NameDraft(name) => {
1569 + if let Some(draft) = state.classifier.editing.as_mut() {
1570 + draft.name = name;
1571 + // Typing into the name is what clears the refusal, exactly
1572 + // as the shipped field's `changed()` did.
1573 + draft.save_refused = false;
1574 + }
1575 + }
1576 + Intent::EnableDraft(on) => {
1577 + if let Some(draft) = state.classifier.editing.as_mut() {
1578 + draft.enabled = on;
1579 + }
1580 + }
1581 + Intent::MatchAll(all) => {
1582 + if let Some(draft) = state.classifier.editing.as_mut() {
1583 + draft.match_mode = if all {
1584 + audiofiles_core::rules::MatchMode::All
1585 + } else {
1586 + audiofiles_core::rules::MatchMode::Any
1587 + };
1588 + }
1589 + }
1590 + Intent::AddCondition => {
1591 + if let Some(draft) = state.classifier.editing.as_mut() {
1592 + draft
1593 + .conditions
1594 + .push(audiofiles_core::rules::RuleCondition {
1595 + field: audiofiles_core::rules::RuleField::Name,
1596 + op: audiofiles_core::rules::RuleOp::Contains,
1597 + value: String::new(),
1598 + });
1599 + draft.match_count = None;
1600 + }
1601 + }
1602 + Intent::DropCondition(at) => {
1603 + if let Some(draft) = state.classifier.editing.as_mut()
1604 + && at < draft.conditions.len()
1605 + && draft.conditions.len() > 1
1606 + {
1607 + draft.conditions.remove(at);
1608 + draft.match_count = None;
1609 + }
1610 + }
1611 + Intent::SetCondition(at, part, value) => set_condition(state, at, part, &value),
1612 + Intent::AddAction => {
1613 + if let Some(draft) = state.classifier.editing.as_mut() {
1614 + draft
1615 + .actions
1616 + .push(audiofiles_core::rules::RuleAction::AddTag(String::new()));
1617 + }
1618 + }
1619 + Intent::DropAction(at) => {
1620 + if let Some(draft) = state.classifier.editing.as_mut()
1621 + && at < draft.actions.len()
1622 + {
1623 + draft.actions.remove(at);
1624 + }
1625 + }
1626 + Intent::SetAction(at, part, value) => set_action(state, at, part, &value),
1627 + Intent::TestDraft => state.classifier_test_draft(),
1628 + Intent::SaveDraft => state.classifier_save_draft(),
1629 + Intent::CancelDraft => state.classifier_cancel_draft(),
1630 + Intent::SuggestLibrary => state.classifier_auto_apply_ml(),
1631 + Intent::UndoTagSource(source) => state.undo_tag_source(&source),
1632 + Intent::ReviewLibrary => state.classifier_review_library(),
1633 + Intent::ReopenReview => state.open_review_screen(),
1634 + Intent::TrainHead => state.classifier_train_head(),
1635 + Intent::ClearHead => state.classifier_clear_head(),
1636 + Intent::SetPolicy(tag, review, auto) => {
1637 + state.classifier_set_policy(&tag, review, auto);
1638 + state.refresh_policies();
1639 + }
1640 + Intent::TypingPolicy(tag) => state.classifier.new_policy_tag = tag,
1641 + Intent::AddPolicy => state.classifier_add_policy(),
1642 + Intent::ClusterK(k) => state.classifier.cluster_k = k,
1643 + Intent::FindClusters => state.run_clustering(),
1644 + Intent::PlayCluster(at) => {
1645 + if let Some(hash) = state
1646 + .classifier
1647 + .clusters
1648 + .get(at)
1649 + .and_then(|cluster| cluster.medoid_hash.clone())
1650 + {
1651 + state.trigger_preview(&hash);
1652 + }
1653 + }
1654 + // Committed as it is typed, which is the one behaviour difference
1655 + // in this port. The shipped field wrote through on blur, and a
1656 + // described screen has no blur to write on; the alternative was a
1657 + // submit per pile. The cost is an UPDATE per keystroke against a
1658 + // local table, and the gain is a name that cannot be lost.
1659 + Intent::NameCluster(at, name) => {
1660 + if let Some(held) = state.classifier.cluster_names.get_mut(at) {
1661 + *held = name;
1662 + state.commit_cluster_name(at);
1663 + }
1664 + }
1665 + Intent::TagCluster(at) => state.apply_cluster(at),
1666 + Intent::ScanFolders => state.refresh_folder_labels(),
1667 + Intent::NameFolder(at, tag) => {
1668 + if let Some(held) = state.classifier.folder_tags.get_mut(at) {
1669 + *held = tag;
1670 + }
1671 + }
1672 + Intent::ApplyFolder(at) => state.apply_folder_label(at),
1673 + Intent::NameExport(name) => state.classifier.export_name = name,
1674 + Intent::Include(part, on) => match part {
1675 + super::Shareable::Exemplars => state.classifier.export_include_exemplars = on,
1676 + super::Shareable::Rules => state.classifier.export_include_rules = on,
1677 + super::Shareable::Thresholds => state.classifier.export_include_policy = on,
1678 + },
1679 + // The host act with no described step, for the reason the module
1680 + // header gives: the export is built on a worker and answers through
1681 + // `BackendEvent`, so there are no bytes to hand back when the route
1682 + // answers.
1683 + Intent::ExportClassifier => {
1684 + let file = format!("{}.afcl", export_filename(&state.classifier.export_name));
1685 + state.dialogs.save_file(
1686 + "Export classifier",
1687 + file,
1688 + &[("AF classifier", &["afcl"])],
1689 + |state, path| state.classifier_export_afcl(&path),
1690 + );
1691 + }
1692 + Intent::ImportClassifier => {
1693 + state.dialogs.pick_file(
1694 + "Import classifier",
1695 + &[("AF classifier", &["afcl"])],
1696 + |state, path| state.classifier_import_afcl(&path),
1697 + );
1698 + }
1699 + Intent::EnableLayer(id, on) => state.classifier_set_layer_enabled(&id, on),
1700 + Intent::WeighLayer(id, weight) => state.classifier_set_layer_weight(&id, weight),
1701 + Intent::RemoveLayer(id) => state.classifier_remove_layer(&id),
1540 1702 }
1541 1703 }
1542 1704 }
@@ -1570,6 +1732,96 @@
1570 1732 state.described.settings = None;
1571 1733 }
1572 1734
1735 + /// Set one part of one condition of the rule being authored.
1736 + ///
1737 + /// The coercion is here rather than in the route because it is the app's rule:
1738 + /// changing the field can strand the operator, and `coerce` is what the shipped
1739 + /// editor did on the way past every frame.
1740 + fn set_condition(state: &mut BrowserState, at: usize, part: super::Part, value: &str) {
1741 + let Some(draft) = state.classifier.editing.as_mut() else {
1742 + return;
1743 + };
1744 + let Some(condition) = draft.conditions.get_mut(at) else {
1745 + return;
1746 + };
1747 + match part {
1748 + super::Part::Field => {
1749 + let Some(field) = super::field_from(value) else {
1750 + return;
1751 + };
1752 + condition.field = field;
1753 + condition.op = super::coerce(field, condition.op);
1754 + }
1755 + super::Part::Op => {
1756 + let Some(op) = super::op_from(condition.field, value) else {
1757 + return;
1758 + };
1759 + condition.op = op;
1760 + }
1761 + super::Part::Value => value.clone_into(&mut condition.value),
1762 + super::Part::Kind => return,
1763 + }
1764 + // Any edit invalidates the dry run, which is what the shipped editor did
1765 + // when a condition was added or removed and did not do when one was
1766 + // retyped. The count is about the conditions as they stand.
1767 + draft.match_count = None;
1768 + }
1769 +
1770 + /// Set one part of one action of the rule being authored.
1771 + fn set_action(state: &mut BrowserState, at: usize, part: super::Part, value: &str) {
1772 + use audiofiles_core::rules::RuleAction::{AddTag, RemoveTag, Stop};
1773 + let Some(draft) = state.classifier.editing.as_mut() else {
1774 + return;
1775 + };
1776 + let Some(action) = draft.actions.get_mut(at) else {
1777 + return;
1778 + };
1779 + match part {
1780 + super::Part::Kind => {
1781 + // The tag survives a change of kind, which is what the shipped
1782 + // editor carried across its three-way combo.
1783 + let tag = match action {
1784 + AddTag(tag) | RemoveTag(tag) => tag.clone(),
1785 + Stop => String::new(),
1786 + };
1787 + *action = match value {
1788 + "add" => AddTag(tag),
1789 + "remove" => RemoveTag(tag),
1790 + "stop" => Stop,
1791 + _ => return,
1792 + };
1793 + }
1794 + super::Part::Value => match action {
1795 + AddTag(tag) | RemoveTag(tag) => value.clone_into(tag),
1796 + Stop => {}
1797 + },
1798 + super::Part::Field | super::Part::Op => {}
1799 + }
1800 + }
1801 +
1802 + /// A filename an export can be written under.
1803 + ///
1804 + /// Lifted verbatim from the deleted `ui/classifier.rs`, where it was
1805 + /// `sanitize_filename`, underscore and all.
1806 + fn export_filename(name: &str) -> String {
1807 + let cleaned: String = name
1808 + .trim()
1809 + .chars()
1810 + .map(|c| {
1811 + if c.is_alphanumeric() || c == '-' || c == '_' {
1812 + c
1813 + } else {
1814 + '_'
1815 + }
1816 + })
1817 + .collect();
1818 + if cleaned.is_empty() {
1819 + "classifier".to_owned()
1820 + } else {
1821 + cleaned
1822 + }
1823 + }
1824 +
1573 1825 /// Put a tag on the selected sample, the way the shipped panel does.
1574 1826 ///
1575 1827 /// Validated here rather than in the route, because validation is the app's:
@@ -2343,6 +2595,7 @@
2343 2595 let filters = FromFilters { state, intents };
2344 2596 let storage = FromStorage { state, intents };
2345 2597 let trash = FromTrash { state, intents };
2598 + let classifier = FromClassifier { state, intents };
2346 2599 let panels = Panels {
2347 2600 config: &config,
2348 2601 sync,
@@ -2362,6 +2615,7 @@
2362 2615 filters: &filters,
2363 2616 storage: &storage,
2364 2617 trash: &trash,
2618 + classifier: &classifier,
2365 2619 themes,
2366 2620 };
2367 2621 super::router()
@@ -42,7 +42,7 @@
42 42 //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog |
43 43 //! | License | **no** | a key exchanged with a server |
44 44 //! | Trash | yes | as of the section's own port; see [`trash`](super::trash) |
45 - //! | Classifier | **no** | its own model state, and bespoke |
45 + //! | Classifier | yes | not a section at all; see [`classifier`](super::classifier) |
46 46 //!
47 47 //! Storage was the honest kind of "no" until it was counted. It is described
48 48 //! now, in [`storage`](super::storage), and what its header records is that two
@@ -204,6 +204,20 @@
204 204 body = super::storage::section(body, state);
205 205 body = super::trash::section(body, state);
206 206
207 + // The classifier's door. It was five sections drawn inline here until
208 + // 2026-08-25, when Max ruled it a window of its own; what stays behind is
209 + // one act, so the thing is still findable from where it used to live.
210 + // See `super::classifier`'s header.
211 + body = body
212 + .with(Node::section("Tagging"))
213 + .with(Node::text(
214 + "Rules, auto-tagging, clustering and folder tags, with the classifiers you have imported.",
215 + ))
216 + .with(Node::Act(Act::new(
217 + "Tag classifier...",
218 + Action::get("/classifier"),
219 + )));
220 +
207 221 // Advanced, half of it. See the header: Export Current is describable as of
208 222 // quasi 0.50.0 and Import Theme is not, so the section is what the
209 223 // vocabulary can say rather than all-or-nothing.
@@ -12,15 +12,16 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown,
16 - Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed, DeviceChoice, Draft, Editing,
17 - Export, Failure, Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format,
18 - Group, Halted, Holding, Importing, Integrity, Keys, Knob, Library, LibraryEntry, Measure,
19 - Measures, Migrating, Naming, Narrowing, Order, Panel, Panels, Phase, Playing, Preflight,
20 - Pricing, ProfileChoice, Queue, Queued, Reviewed, Sample, Saying, Scan, Scope, Searching,
21 - Setting, Settings, Shared, Shell, Source, Spread, Stage, State, Status, Storage, Strategy,
22 - Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Trash, Vault,
23 - VaultChoice, Walked, Where, router,
15 + Analysed, Analysis, Authoring, Bar, Bulk, Candidate, Channels, Chop, Chosen, Classifier,
16 + Collection, ColumnsShown, Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed,
17 + DeviceChoice, Doing, Draft, Editing, Export, Failure, Files, Filter, Filters, Focus, Folder,
18 + FolderTags, Forge, Forging, Format, Group, Halted, Harvested, Head, Holding, Importing,
19 + Integrity, Keys, Knob, Layer, Library, LibraryEntry, Measure, Measures, Migrating, Naming,
20 + Narrowing, Order, Panel, Panels, Part, Phase, Pile, Playing, Policy, Preflight, Pricing,
21 + ProfileChoice, Queue, Queued, Readiness, Reviewed, Ruling, Sample, Saying, Scan, Scope,
22 + Searching, Setting, Settings, Shareable, Shared, Sharing, Shell, Source, Spread, Stage, State,
23 + Status, Storage, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged,
24 + Testable, Testing, ThemeChoice, Trash, Vault, VaultChoice, Walked, Where, router,
24 25 };
25 26
26 27 /// A config store in memory.
@@ -267,6 +268,7 @@
267 268 filters: &Unfiltered,
268 269 storage: &OneLibrary,
269 270 trash: &Emptied,
271 + classifier: &Untrained,
270 272 themes: &themes,
271 273 };
272 274 router().handle(&state, request)
@@ -434,6 +436,7 @@
434 436 filters: &Unfiltered,
435 437 storage: &OneLibrary,
436 438 trash: &Emptied,
439 + classifier: &Untrained,
437 440 themes: &themes,
438 441 };
439 442 router().handle(&state, request)
@@ -497,6 +500,7 @@
497 500 filters: &Unfiltered,
498 501 storage: &OneLibrary,
499 502 trash: &Emptied,
503 + classifier: &Untrained,
500 504 themes: &themes,
501 505 };
502 506 router().handle(&state, request)
@@ -663,6 +667,7 @@
663 667 filters: &Unfiltered,
664 668 storage: &OneLibrary,
665 669 trash: &Emptied,
670 + classifier: &Untrained,
666 671 themes: &themes,
667 672 };
668 673 let response = router()
@@ -745,6 +750,7 @@
745 750 filters: &Unfiltered,
746 751 storage: &OneLibrary,
747 752 trash: &Emptied,
753 + classifier: &Untrained,
748 754 themes: &themes,
749 755 };
750 756 router().handle(&state, request)
@@ -818,6 +824,7 @@
818 824 filters: &Unfiltered,
819 825 storage: &OneLibrary,
820 826 trash: &Emptied,
827 + classifier: &Untrained,
821 828 themes: &themes,
822 829 };
823 830
@@ -871,6 +878,7 @@
871 878 filters: &Unfiltered,
872 879 storage: &OneLibrary,
873 880 trash: &Emptied,
881 + classifier: &Untrained,
874 882 themes: &themes,
875 883 };
876 884 let refused = router().handle(
@@ -910,6 +918,7 @@
910 918 filters: &Unfiltered,
911 919 storage: &OneLibrary,
912 920 trash: &Emptied,
921 + classifier: &Untrained,
913 922 themes: &themes,
914 923 };
915 924
@@ -972,6 +981,7 @@
972 981 filters: &Unfiltered,
973 982 storage: &OneLibrary,
974 983 trash: &Emptied,
984 + classifier: &Untrained,
975 985 themes: &themes,
976 986 };
977 987 let response = router()
@@ -1030,6 +1040,7 @@
1030 1040 filters: &Unfiltered,
1031 1041 storage: &OneLibrary,
1032 1042 trash: &Emptied,
1043 + classifier: &Untrained,
1033 1044 themes: &themes,
1034 1045 };
1035 1046 let response = router()
@@ -1201,6 +1212,7 @@
1201 1212 filters: &Unfiltered,
1202 1213 storage: &OneLibrary,
1203 1214 trash: &Emptied,
1215 + classifier: &Untrained,
1204 1216 themes: &themes,
1205 1217 };
1206 1218 router().handle(&state, request)
@@ -2828,6 +2840,7 @@
2828 2840 filters: &Unfiltered,
2829 2841 storage: &OneLibrary,
2830 2842 trash: &Emptied,
2843 + classifier: &Untrained,
2831 2844 themes: &themes,
2832 2845 };
2833 2846 router().handle(&state, request)
@@ -3384,6 +3397,7 @@
3384 3397 filters: &Unfiltered,
3385 3398 storage: &OneLibrary,
3386 3399 trash: &Emptied,
3400 + classifier: &Untrained,
3387 3401 themes: &themes,
3388 3402 };
3389 3403 router().handle(&state, request)
@@ -3775,6 +3789,7 @@
3775 3789 filters: &Unfiltered,
3776 3790 storage: &OneLibrary,
3777 3791 trash: &Emptied,
3792 + classifier: &Untrained,
3778 3793 themes: &themes,
3779 3794 };
3780 3795 router().handle(&state, request)
@@ -4153,6 +4168,7 @@
4153 4168 filters: &Unfiltered,
4154 4169 storage: &OneLibrary,
4155 4170 trash: &Emptied,
4171 + classifier: &Untrained,
4156 4172 themes: &themes,
4157 4173 };
4158 4174 router().handle(&state, request)
@@ -4576,6 +4592,7 @@
4576 4592 filters: &Unfiltered,
4577 4593 storage: &OneLibrary,
4578 4594 trash: &Emptied,
4595 + classifier: &Untrained,
4579 4596 themes: &themes,
4580 4597 };
4581 4598 router().handle(&state, request)
@@ -5065,6 +5082,7 @@
5065 5082 filters: &Unfiltered,
5066 5083 storage: &OneLibrary,
5067 5084 trash: &Emptied,
5085 + classifier: &Untrained,
5068 5086 themes: &themes,
5069 5087 };
5070 5088 router().handle(&state, request)
@@ -5819,6 +5837,113 @@
5819 5837 fn purge(&self, _hash: &str) {}
5820 5838 }
5821 5839
5840 + /// Nothing tagged, nothing trained, and no rules.
5841 + ///
5842 + /// The quiet fixture, matching [`OneLibrary`] and [`Emptied`]. The screen's own
5843 + /// tests use [`FakeClassifier`], which holds state and records.
5844 + struct Untrained;
5845 +
5846 + impl Classifier for Untrained {
5847 + fn busy(&self) -> Option<String> {
5848 + None
5849 + }
5850 + fn failed(&self) -> Option<String> {
5851 + None
5852 + }
5853 + fn rules(&self) -> Vec<Ruling> {
5854 + Vec::new()
5855 + }
5856 + fn testable(&self) -> Vec<Testable> {
5857 + Vec::new()
5858 + }
5859 + fn authoring(&self) -> Option<Authoring> {
5860 + None
5861 + }
5862 + fn last_apply(&self) -> Option<usize> {
5863 + None
5864 + }
5865 + fn last_suggest(&self) -> Option<usize> {
5866 + None
5867 + }
5868 + fn last_review(&self) -> Option<String> {
5869 + None
5870 + }
5871 + fn waiting(&self) -> usize {
5872 + 0
5873 + }
5874 + fn head(&self) -> Option<Head> {
5875 + None
5876 + }
5877 + fn readiness(&self) -> Option<Readiness> {
5878 + None
5879 + }
5880 + fn policies(&self) -> Vec<Policy> {
5881 + Vec::new()
5882 + }
5883 + fn policy_typing(&self) -> String {
5884 + String::new()
5885 + }
5886 + fn cluster_k(&self) -> u32 {
5887 + 8
5888 + }
5889 + fn clusters(&self) -> Vec<Pile> {
5890 + Vec::new()
5891 + }
5892 + fn folders(&self) -> Option<Vec<Harvested>> {
5893 + None
5894 + }
5895 + fn sharing(&self) -> Sharing {
5896 + Sharing::default()
5897 + }
5898 + fn layers(&self) -> Vec<Layer> {
5899 + Vec::new()
5900 + }
5901 +
5902 + fn new_rule(&self) {}
5903 + fn apply_rules(&self) {}
5904 + fn seed_rules(&self) {}
5905 + fn enable_rule(&self, _id: &str, _on: bool) {}
5906 + fn edit_rule(&self, _id: &str) {}
5907 + fn delete_rule(&self, _id: &str) {}
5908 + fn move_rule(&self, _id: &str, _up: bool) {}
5909 + fn name_draft(&self, _name: &str) {}
5910 + fn enable_draft(&self, _on: bool) {}
5911 + fn match_all(&self, _all: bool) {}
5912 + fn add_condition(&self) {}
5913 + fn drop_condition(&self, _at: usize) {}
5914 + fn set_condition(&self, _at: usize, _part: Part, _value: &str) {}
5915 + fn add_action(&self) {}
5916 + fn drop_action(&self, _at: usize) {}
5917 + fn set_action(&self, _at: usize, _part: Part, _value: &str) {}
5918 + fn test_draft(&self) {}
5919 + fn save_draft(&self) {}
5920 + fn cancel_draft(&self) {}
5921 + fn suggest(&self) {}
5922 + fn undo_source(&self, _source: &str) {}
5923 + fn review(&self) {}
5924 + fn reopen_review(&self) {}
5925 + fn train(&self) {}
5926 + fn clear_head(&self) {}
5927 + fn set_policy(&self, _tag: &str, _review: f64, _auto: f64) {}
5928 + fn typing_policy(&self, _tag: &str) {}
5929 + fn add_policy(&self) {}
5930 + fn set_cluster_k(&self, _k: u32) {}
5931 + fn find_clusters(&self) {}
5932 + fn play_cluster(&self, _at: usize) {}
5933 + fn name_cluster(&self, _at: usize, _name: &str) {}
5934 + fn tag_cluster(&self, _at: usize) {}
5935 + fn scan_folders(&self) {}
5936 + fn name_folder(&self, _at: usize, _tag: &str) {}
5937 + fn apply_folder(&self, _at: usize) {}
5938 + fn name_export(&self, _name: &str) {}
5939 + fn include(&self, _part: Shareable, _on: bool) {}
5940 + fn export(&self) {}
5941 + fn import(&self) {}
5942 + fn enable_layer(&self, _id: &str, _on: bool) {}
5943 + fn weigh_layer(&self, _id: &str, _weight: f64) {}
5944 + fn remove_layer(&self, _id: &str) {}
5945 + }
5946 +
5822 5947 /// A namer in memory, recording what was asked of it and refusing on demand.
5823 5948 #[derive(Default)]
5824 5949 struct FakeNaming {
@@ -5942,6 +6067,7 @@
5942 6067 filters: &Unfiltered,
5943 6068 storage: &OneLibrary,
5944 6069 trash: &Emptied,
6070 + classifier: &Untrained,
5945 6071 themes: &themes,
5946 6072 };
5947 6073 router().handle(&state, request)
@@ -6106,6 +6232,7 @@
6106 6232 filters: &Unfiltered,
6107 6233 storage: &OneLibrary,
6108 6234 trash: &Emptied,
6235 + classifier: &Untrained,
6109 6236 themes: &themes,
6110 6237 };
6111 6238 router().handle(&state, request)
@@ -6414,6 +6541,7 @@
6414 6541 filters: &Unfiltered,
6415 6542 storage: &OneLibrary,
6416 6543 trash: &Emptied,
6544 + classifier: &Untrained,
6417 6545 themes: &themes,
6418 6546 };
6419 6547 router().handle(&state, request)
@@ -6499,6 +6627,7 @@
6499 6627 filters: &Unfiltered,
6500 6628 storage: &OneLibrary,
6501 6629 trash: &Emptied,
6630 + classifier: &Untrained,
6502 6631 themes: &themes,
6503 6632 };
6504 6633 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -6682,6 +6811,7 @@
6682 6811 filters: &Unfiltered,
6683 6812 storage: &OneLibrary,
6684 6813 trash: &Emptied,
6814 + classifier: &Untrained,
6685 6815 themes: &themes,
6686 6816 };
6687 6817 router().handle(&state, request)
@@ -8261,6 +8391,7 @@
8261 8391 filters: &Unfiltered,
8262 8392 storage: &OneLibrary,
8263 8393 trash: &Emptied,
8394 + classifier: &Untrained,
8264 8395 themes: &themes,
8265 8396 };
8266 8397 router().handle(&state, request)
@@ -8843,6 +8974,7 @@
8843 8974 filters,
8844 8975 storage: &OneLibrary,
8845 8976 trash: &Emptied,
8977 + classifier: &Untrained,
8846 8978 themes: &themes,
8847 8979 };
8848 8980 router().handle(&state, request)
@@ -8983,6 +9115,7 @@
8983 9115 filters: &Unfiltered,
8984 9116 storage: &OneLibrary,
8985 9117 trash: &Emptied,
9118 + classifier: &Untrained,
8986 9119 themes: &themes,
8987 9120 };
8988 9121 router().handle(&state, request)
@@ -9676,6 +9809,7 @@
9676 9809 filters: &Unfiltered,
9677 9810 storage,
9678 9811 trash: &Emptied,
9812 + classifier: &Untrained,
9679 9813 themes: &themes,
9680 9814 };
9681 9815 router().handle(&state, request)
@@ -10099,6 +10233,7 @@
10099 10233 filters: &Unfiltered,
10100 10234 storage: &OneLibrary,
10101 10235 trash,
10236 + classifier: &Untrained,
10102 10237 themes: &themes,
10103 10238 };
10104 10239 router().handle(&state, request)
@@ -10265,3 +10400,896 @@
10265 10400 );
10266 10401 }
10267 10402 }
10403 +
10404 + /// A classifier in memory, recording what was asked of it.
10405 + #[derive(Default)]
10406 + struct FakeClassifier {
10407 + busy: Option<String>,
10408 + failed: Option<String>,
10409 + rules: Vec<Ruling>,
10410 + authoring: Option<Authoring>,
10411 + waiting: usize,
10412 + head: Option<Head>,
10413 + readiness: Option<Readiness>,
10414 + policies: Vec<Policy>,
10415 + clusters: Vec<Pile>,
10416 + folders: Option<Vec<Harvested>>,
10417 + sharing: Sharing,
10418 + layers: Vec<Layer>,
10419 + asked: RefCell<Vec<String>>,
10420 + }
10421 +
10422 + impl FakeClassifier {
10423 + /// Two rules, nothing else going on.
10424 + fn two_rules() -> Self {
10425 + Self {
10426 + rules: vec![
10427 + Ruling {
10428 + id: "r1".to_owned(),
10429 + name: "Kick drums".to_owned(),
10430 + enabled: true,
10431 + conditions: 2,
10432 + actions: 1,
10433 + first: true,
10434 + last: false,
10435 + },
10436 + Ruling {
10437 + id: "r2".to_owned(),
10438 + name: String::new(),
10439 + enabled: false,
10440 + conditions: 1,
10441 + actions: 1,
10442 + first: false,
10443 + last: true,
10444 + },
10445 + ],
10446 + ..Self::default()
10447 + }
10448 + }
10449 +
10450 + /// An editor open on one condition and one action.
10451 + fn editing() -> Self {
10452 + Self {
10453 + authoring: Some(Authoring {
10454 + name: "Kick drums".to_owned(),
10455 + enabled: true,
10456 + all: true,
10457 + conditions: vec![Testing {
10458 + field: "name".to_owned(),
10459 + op: "contains".to_owned(),
10460 + value: "kick".to_owned(),
10461 + }],
10462 + actions: vec![Doing {
10463 + kind: "add".to_owned(),
10464 + tag: "instrument.drum.kick".to_owned(),
10465 + }],
10466 + matched: None,
10467 + refused: false,
10468 + }),
10469 + ..Self::default()
10470 + }
10471 + }
10472 +
10473 + fn asked(&self) -> Vec<String> {
10474 + self.asked.borrow().clone()
10475 + }
10476 +
10477 + fn note(&self, what: impl Into<String>) {
10478 + self.asked.borrow_mut().push(what.into());
10479 + }
10480 + }
10481 +
10482 + impl Classifier for FakeClassifier {
10483 + fn busy(&self) -> Option<String> {
10484 + self.busy.clone()
10485 + }
10486 + fn failed(&self) -> Option<String> {
10487 + self.failed.clone()
10488 + }
10489 + fn rules(&self) -> Vec<Ruling> {
10490 + self.rules.clone()
10491 + }
10492 + /// The real vocabulary, so the screen is tested against what it will be
10493 + /// handed rather than against a stub of it.
10494 + fn testable(&self) -> Vec<Testable> {
10495 + super::rule_fields()
10496 + .iter()
10497 + .map(|field| Testable {
10498 + value: super::field_value(*field).to_owned(),
10499 + label: super::field_label(*field).to_owned(),
10500 + ops: super::ops_for(*field)
10501 + .iter()
10502 + .map(|op| super::Comparison {
10503 + value: super::op_value(*op).to_owned(),
10504 + label: super::op_label(*op).to_owned(),
10505 + takes_value: super::op_takes_value(*op),
10506 + })
10507 + .collect(),
10508 + })
10509 + .collect()
10510 + }
10511 + fn authoring(&self) -> Option<Authoring> {
10512 + self.authoring.clone()
10513 + }
10514 + fn last_apply(&self) -> Option<usize> {
10515 + None
10516 + }
10517 + fn last_suggest(&self) -> Option<usize> {
10518 + None
10519 + }
10520 + fn last_review(&self) -> Option<String> {
10521 + None
10522 + }
10523 + fn waiting(&self) -> usize {
10524 + self.waiting
10525 + }
10526 + fn head(&self) -> Option<Head> {
10527 + self.head
10528 + }
10529 + fn readiness(&self) -> Option<Readiness> {
10530 + self.readiness
10531 + }
10532 + fn policies(&self) -> Vec<Policy> {
10533 + self.policies.clone()
10534 + }
10535 + fn policy_typing(&self) -> String {
10536 + String::new()
10537 + }
10538 + fn cluster_k(&self) -> u32 {
10539 + 8
10540 + }
10541 + fn clusters(&self) -> Vec<Pile> {
10542 + self.clusters.clone()
10543 + }
10544 + fn folders(&self) -> Option<Vec<Harvested>> {
10545 + self.folders.clone()
10546 + }
10547 + fn sharing(&self) -> Sharing {
10548 + self.sharing.clone()
10549 + }
10550 + fn layers(&self) -> Vec<Layer> {
10551 + self.layers.clone()
10552 + }
10553 +
10554 + fn new_rule(&self) {
10555 + self.note("new_rule");
10556 + }
10557 + fn apply_rules(&self) {
10558 + self.note("apply_rules");
10559 + }
10560 + fn seed_rules(&self) {
10561 + self.note("seed_rules");
10562 + }
10563 + fn enable_rule(&self, id: &str, on: bool) {
10564 + self.note(format!("enable_rule {id} {on}"));
10565 + }
10566 + fn edit_rule(&self, id: &str) {
10567 + self.note(format!("edit_rule {id}"));
10568 + }
10569 + fn delete_rule(&self, id: &str) {
10570 + self.note(format!("delete_rule {id}"));
10571 + }
10572 + fn move_rule(&self, id: &str, up: bool) {
Lines truncated
@@ -330,7 +330,8 @@
330 330 pub trash_loaded: bool,
331 331 }
332 332
333 - /// GUI state for the tag-classifier sections in Settings (Layer A rules builder).
333 + /// GUI state for the tag classifier, which is a window of its own since
334 + /// 2026-08-25 (Max's ruling; it had been a Settings section).
334 335 #[derive(Default)]
335 336 pub struct ClassifierUiState {
336 337 /// Cached rules, refreshed when the section opens and after edits.
@@ -402,8 +403,6 @@
402 403 pub busy: Option<String>,
403 404 /// Last classifier-job error, shown as an in-section banner.
404 405 pub last_error: Option<String>,
405 - /// Layer id awaiting a remove confirmation (inline two-step confirm).
406 - pub pending_layer_remove: Option<String>,
407 406 }
408 407
409 408 /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule`
@@ -1,6 +1,5 @@
1 1 //! UI submodules: each panel and widget type in its own file.
2 2
3 - pub mod classifier;
4 3 pub mod color;
5 4 pub mod dialog;
6 5 pub mod file_list_menus;
@@ -1,0 +1,1310 @@
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 2026-08-25 (Max): one screen at `/classifier`, five sections inside
18 + //! it, with the door in Settings.** Not five addresses with a navigation
19 + //! between them, which was the alternative: the five are read together and the
20 + //! rules editor is the only one that takes 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: a repeating question repeats one field, and a condition is three
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 does not use it. Each condition is a [`Node::Region`] holding
58 + //! its three fields, with its own remove act, and "Add condition" is an act
59 + //! rather than a renderer's add control. That says everything honestly and
60 + //! loses one thing the member would have given: a renderer has no way to know
61 + //! the regions are slots of one repeating question, so nothing can draw them as
62 + //! a list that grows. Filed on quasicoherent rather than worked around further.
63 + //!
64 + //! Two consumers here, not one: conditions (three fields) and actions (two).
65 + //! Both are in the same editor, which is what makes this a shape rather than a
66 + //! quirk of one screen.
67 + //!
68 + //! # THE SECOND FINDING: a description cannot say "write when the control settles"
69 + //!
70 + //! The shipped thresholds and the layer weights both commit through
71 + //! `widgets::settled`, so a drag across a slider is one write rather than one
72 + //! per frame. [`Field::changes`] is the only thing the vocabulary has and it
73 + //! means every change. Three sliders here take it, so a drag is now a write per
74 + //! step: correct, and chattier than the shipped screen against a store that is
75 + //! local.
76 + //!
77 + //! Not worked around, because the workaround would be worse — a submit button
78 + //! per slider, or a debounce invented in one renderer. Filed with a count.
79 + //!
80 + //! # Two `ConfirmAction`-shaped state machines die here
81 + //!
82 + //! `pending_layer_remove` armed a layer's Remove and swapped the row for a
83 + //! Cancel/Remove pair, exactly as `trash_confirm_purge` did before
84 + //! [`trash`](super::trash) deleted it. [`Act::confirm`] again, and the field
85 + //! goes with it. That is the fifth and sixth variant of that pattern this port
86 + //! has replaced with a builder call.
87 + //!
88 + //! # What stays an intent because the host has to be asked
89 + //!
90 + //! Export writes a file the reader names a place for, and import reads one.
91 + //! Export could be [`Outcome::File`] — [`settings`](super::settings)'s theme
92 + //! export is that member's first consumer here — but the classifier's export is
93 + //! built on a worker thread and answers through `BackendEvent`, so the route
94 + //! has no bytes to hand over when it answers. Import is the gap
95 + //! [`settings`](super::settings) already records: `FieldKind::File` says what
96 + //! may be picked and nothing carries the picked file's bytes to a route on this
97 + //! host. Both are ordinary acts, and both are the same host plumbing the import
98 + //! flow's four doors take.
99 + //!
100 + //! [`Act::confirm`]: quasi_router::Act::confirm
101 + //! [`Classifier::testable`]: super::Classifier::testable
102 + //! [`Field::changes`]: quasi_router::Field::changes
103 + //! [`Field::repeats`]: quasi_router::Field::repeats
104 + //! [`Outcome::File`]: quasi_router::Outcome::File
105 + //! [`Repeat`]: quasi_router::Repeat
106 +
107 + use quasi_router::layout::{FieldKind, Notice, Readiness as Ready, Tone};
108 + use quasi_router::{
109 + Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Row,
110 + Screen, Slot, Tag,
111 + };
112 +
113 + use super::{Panels, Part, Shareable};
114 +
115 + /// The region the whole screen answers into.
116 + const BODY: &str = "classifier-body";
117 +
118 + /// What removing a layer takes with it.
119 + const DROPS_LAYER: &str =
120 + "Permanently delete this layer and its imported rules? This cannot be undone.";
121 +
122 + /// Register the classifier's routes.
123 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
124 + let router = router
125 + .get("/classifier", index)
126 + .post("/classifier/rules/new", new_rule)
127 + .post("/classifier/rules/apply", apply_rules)
128 + .post("/classifier/rules/starter", seed_rules)
129 + .post("/classifier/rules/{id}/enabled", enable_rule)
130 + .post("/classifier/rules/{id}/edit", edit_rule)
131 + .post("/classifier/rules/{id}/delete", delete_rule)
132 + .post("/classifier/rules/{id}/up", move_up)
133 + .post("/classifier/rules/{id}/down", move_down);
134 +
135 + let router = router
136 + .post("/classifier/draft/name", name_draft)
137 + .post("/classifier/draft/enabled", enable_draft)
138 + .post("/classifier/draft/match", match_mode)
139 + .post("/classifier/draft/conditions/add", add_condition)
140 + .post("/classifier/draft/conditions/{at}/remove", drop_condition)
141 + .post(
142 + "/classifier/draft/conditions/{at}/set/{part}",
143 + set_condition,
144 + )
145 + .post("/classifier/draft/actions/add", add_action)
146 + .post("/classifier/draft/actions/{at}/remove", drop_action)
147 + .post("/classifier/draft/actions/{at}/set/{part}", set_action)
148 + .post("/classifier/draft/test", test_draft)
149 + .post("/classifier/draft/save", save_draft)
150 + .post("/classifier/draft/cancel", cancel_draft);
151 +
152 + let router = router
153 + .post("/classifier/autotag/suggest", suggest)
154 + .post("/classifier/autotag/review", review)
155 + .post("/classifier/autotag/reopen", reopen)
156 + .post("/classifier/head/train", train)
157 + .post("/classifier/head/clear", clear_head)
158 + .post("/classifier/policies/typing", typing_policy)
159 + .post("/classifier/policies/add", add_policy)
160 + .post("/classifier/policies/{tag}/thresholds", set_policy);
161 +
162 + let router = router
163 + .post("/classifier/clusters/k", cluster_k)
164 + .post("/classifier/clusters/find", find_clusters)
165 + .post("/classifier/clusters/{at}/play", play_cluster)
166 + .post("/classifier/clusters/{at}/name", name_cluster)
167 + .post("/classifier/clusters/{at}/tag", tag_cluster)
168 + .post("/classifier/folders/scan", scan_folders)
169 + .post("/classifier/folders/{at}/tag", name_folder)
170 + .post("/classifier/folders/{at}/apply", apply_folder)
171 + .post("/classifier/undo/{source}", undo_source);
172 +
173 + router
174 + .post("/classifier/sharing/name", name_export)
175 + .post("/classifier/sharing/include/{part}", include)
176 + .post("/classifier/sharing/export", export)
177 + .post("/classifier/sharing/import", import)
178 + .post("/classifier/layers/{id}/enabled", enable_layer)
179 + .post("/classifier/layers/{id}/weight", weigh_layer)
180 + .post("/classifier/layers/{id}/remove", remove_layer)
181 + }
182 +
183 + // --- Rules -------------------------------------------------------------------
184 +
185 + /// `GET /classifier`
186 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
187 + Ok(screen(state).into())
188 + }
189 +
190 + /// `POST /classifier/rules/new`
191 + fn new_rule(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
192 + state.classifier.new_rule();
193 + settled(state)
194 + }
195 +
196 + /// `POST /classifier/rules/apply`
197 + fn apply_rules(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
198 + state.classifier.apply_rules();
199 + settled(state)
200 + }
201 +
202 + /// `POST /classifier/rules/starter`
203 + fn seed_rules(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
204 + state.classifier.seed_rules();
205 + settled(state)
206 + }
207 +
208 + /// `POST /classifier/rules/{id}/enabled`
209 + fn enable_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
210 + let id = named(&request, "id")?;
211 + state.classifier.enable_rule(id, ticked(&request, "on"));
212 + settled(state)
213 + }
214 +
215 + /// `POST /classifier/rules/{id}/edit`
216 + fn edit_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
217 + state.classifier.edit_rule(named(&request, "id")?);
218 + settled(state)
219 + }
220 +
221 + /// `POST /classifier/rules/{id}/delete`
222 + fn delete_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
223 + state.classifier.delete_rule(named(&request, "id")?);
224 + settled(state)
225 + }
226 +
227 + /// `POST /classifier/rules/{id}/up`
228 + fn move_up(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
229 + state.classifier.move_rule(named(&request, "id")?, true);
230 + settled(state)
231 + }
232 +
233 + /// `POST /classifier/rules/{id}/down`
234 + fn move_down(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
235 + state.classifier.move_rule(named(&request, "id")?, false);
236 + settled(state)
237 + }
238 +
239 + // --- The rule being authored -------------------------------------------------
240 +
241 + /// `POST /classifier/draft/name`
242 + fn name_draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
243 + state
244 + .classifier
245 + .name_draft(request.payload.get("name").unwrap_or_default());
246 + settled(state)
247 + }
248 +
249 + /// `POST /classifier/draft/enabled`
250 + fn enable_draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
251 + state.classifier.enable_draft(ticked(&request, "enabled"));
252 + settled(state)
253 + }
254 +
255 + /// `POST /classifier/draft/match`
256 + fn match_mode(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
257 + let answered = request.payload.get("match").unwrap_or_default();
258 + state.classifier.match_all(answered != "any");
259 + settled(state)
260 + }
261 +
262 + /// `POST /classifier/draft/conditions/add`
263 + fn add_condition(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
264 + state.classifier.add_condition();
265 + settled(state)
266 + }
267 +
268 + /// `POST /classifier/draft/conditions/{at}/remove`
269 + fn drop_condition(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
270 + state.classifier.drop_condition(slot(&request)?);
271 + settled(state)
272 + }
273 +
274 + /// `POST /classifier/draft/conditions/{at}/set/{part}`
275 + fn set_condition(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
276 + let at = slot(&request)?;
277 + let part = condition_part(&request)?;
278 + let name = request.captures.require("part")?;
279 + state
280 + .classifier
281 + .set_condition(at, part, request.payload.get(name).unwrap_or_default());
282 + settled(state)
283 + }
284 +
285 + /// `POST /classifier/draft/actions/add`
286 + fn add_action(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
287 + state.classifier.add_action();
288 + settled(state)
289 + }
290 +
291 + /// `POST /classifier/draft/actions/{at}/remove`
292 + fn drop_action(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
293 + state.classifier.drop_action(slot(&request)?);
294 + settled(state)
295 + }
296 +
297 + /// `POST /classifier/draft/actions/{at}/set/{part}`
298 + fn set_action(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
299 + let at = slot(&request)?;
300 + let name = request.captures.require("part")?;
301 + let part = match name {
302 + "kind" => Part::Kind,
303 + "tag" => Part::Value,
304 + _ => return Err(RouteError::not_found("no such part of an action")),
305 + };
306 + state
307 + .classifier
308 + .set_action(at, part, request.payload.get(name).unwrap_or_default());
309 + settled(state)
310 + }
311 +
312 + /// `POST /classifier/draft/test`
313 + fn test_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
314 + state.classifier.test_draft();
315 + settled(state)
316 + }
317 +
318 + /// `POST /classifier/draft/save`
319 + fn save_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
320 + state.classifier.save_draft();
321 + settled(state)
322 + }
323 +
324 + /// `POST /classifier/draft/cancel`
325 + fn cancel_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
326 + state.classifier.cancel_draft();
327 + settled(state)
328 + }
329 +
330 + // --- Auto-tagging ------------------------------------------------------------
331 +
332 + /// `POST /classifier/autotag/suggest`
333 + fn suggest(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
334 + state.classifier.suggest();
335 + settled(state)
336 + }
337 +
338 + /// `POST /classifier/undo/{source}`
339 + ///
340 + /// One route for the three undo controls, because they are one question with
341 + /// three answers: which pass is being taken back. The shipped screen had the
342 + /// same string in three places.
343 + fn undo_source(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
344 + let source = request.captures.require("source")?;
345 + if !matches!(source, "ml" | "cluster" | "harvest") {
346 + return Err(RouteError::not_found("nothing applied tags that way"));
347 + }
348 + state.classifier.undo_source(source);
349 + settled(state)
350 + }
351 +
352 + /// `POST /classifier/autotag/review`
353 + fn review(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
354 + state.classifier.review();
355 + settled(state)
356 + }
357 +
358 + /// `POST /classifier/autotag/reopen`
359 + fn reopen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
360 + if state.classifier.waiting() == 0 {
361 + return Err(RouteError::not_found("nothing is waiting"));
362 + }
363 + state.classifier.reopen_review();
364 + settled(state)
365 + }
366 +
367 + /// `POST /classifier/head/train`
368 + fn train(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
369 + state.classifier.train();
370 + settled(state)
371 + }
372 +
373 + /// `POST /classifier/head/clear`
374 + fn clear_head(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
375 + state.classifier.clear_head();
376 + settled(state)
377 + }
378 +
379 + /// `POST /classifier/policies/{tag}/thresholds`
380 + ///
381 + /// Both ends in one call. They are two controls and one row, and a tag whose
382 + /// review threshold sat above its auto threshold for the length of a round trip
383 + /// would be a state the store should never see.
384 + fn set_policy(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
385 + let tag = request.captures.require("tag")?;
386 + let current = state
387 + .classifier
388 + .policies()
389 + .into_iter()
390 + .find(|policy| policy.tag == tag)
391 + .ok_or_else(|| RouteError::not_found("no threshold for that tag"))?;
392 + let review = number(&request, "review").unwrap_or(current.review);
393 + let auto = number(&request, "auto").unwrap_or(current.auto);
394 + state.classifier.set_policy(tag, review, auto);
395 + settled(state)
396 + }
397 +
398 + /// `POST /classifier/policies/typing`
399 + fn typing_policy(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
400 + state
401 + .classifier
402 + .typing_policy(request.payload.get("tag").unwrap_or_default());
403 + settled(state)
404 + }
405 +
406 + /// `POST /classifier/policies/add`
407 + fn add_policy(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
408 + state.classifier.add_policy();
409 + settled(state)
410 + }
411 +
412 + // --- Clusters and folders ----------------------------------------------------
413 +
414 + /// `POST /classifier/clusters/k`
415 + fn cluster_k(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
416 + let asked = request
417 + .payload
418 + .get("cluster_k")
419 + .and_then(|value| value.parse().ok())
420 + .ok_or_else(|| RouteError::not_found("that is not a number of groups"))?;
421 + state.classifier.set_cluster_k(asked);
422 + settled(state)
423 + }
424 +
425 + /// `POST /classifier/clusters/find`
426 + fn find_clusters(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
427 + state.classifier.find_clusters();
428 + settled(state)
429 + }
430 +
431 + /// `POST /classifier/clusters/{at}/play`
432 + fn play_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
433 + state.classifier.play_cluster(slot(&request)?);
434 + settled(state)
435 + }
436 +
437 + /// `POST /classifier/clusters/{at}/name`
438 + fn name_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
439 + let at = slot(&request)?;
440 + state
441 + .classifier
442 + .name_cluster(at, request.payload.get("name").unwrap_or_default());
443 + settled(state)
444 + }
445 +
446 + /// `POST /classifier/clusters/{at}/tag`
447 + fn tag_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
448 + state.classifier.tag_cluster(slot(&request)?);
449 + settled(state)
450 + }
451 +
452 + /// `POST /classifier/folders/scan`
453 + fn scan_folders(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
454 + state.classifier.scan_folders();
455 + settled(state)
456 + }
457 +
458 + /// `POST /classifier/folders/{at}/tag`
459 + fn name_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
460 + let at = slot(&request)?;
461 + state
462 + .classifier
463 + .name_folder(at, request.payload.get("tag").unwrap_or_default());
464 + settled(state)
465 + }
466 +
467 + /// `POST /classifier/folders/{at}/apply`
468 + fn apply_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
469 + state.classifier.apply_folder(slot(&request)?);
470 + settled(state)
471 + }
472 +
473 + // --- Sharing -----------------------------------------------------------------
474 +
475 + /// `POST /classifier/sharing/name`
476 + fn name_export(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
477 + state
478 + .classifier
479 + .name_export(request.payload.get("export_name").unwrap_or_default());
480 + settled(state)
481 + }
482 +
483 + /// `POST /classifier/sharing/include/{part}`
484 + fn include(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
485 + let name = request.captures.require("part")?;
486 + let part = match name {
487 + "exemplars" => Shareable::Exemplars,
488 + "rules" => Shareable::Rules,
489 + "thresholds" => Shareable::Thresholds,
490 + _ => return Err(RouteError::not_found("nothing of that name is shared")),
491 + };
492 + state.classifier.include(part, ticked(&request, name));
493 + settled(state)
494 + }
495 +
496 + /// `POST /classifier/sharing/export`
497 + fn export(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
498 + if !state.classifier.sharing().worth_writing() {
499 + return Err(RouteError::not_found("there is nothing to export"));
500 + }
Lines truncated
@@ -1,1432 +1,0 @@
1 - //! Tag-classifier UI: the deterministic rules builder (Layer A), drawn as a section
2 - //! inside the Settings window. Reads/mutates `state.classifier`; persistence and
3 - //! reconciliation go through the `classifier_*` methods on `BrowserState`.
4 -
5 - use egui;
6 - use makeover_immediate;
7 - use makeover_layout;
8 -
9 - use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp};
10 -
11 - use super::theme;
12 - use super::widgets;
13 - use crate::state::BrowserState;
14 -
15 - /// A confidence between never and only-on-certainty, as a described field.
16 - ///
17 - /// `makeover-layout` 0.28.0's `FieldKind::Range`. The two ends are the question
18 - /// here and not a rule the answer is checked against: 0 is never and 1 is
19 - /// only-on-certainty, and a typed 0.72 means nothing without both of them on
20 - /// screen. That is why these were the measured sites on the gap (`fb93426b`)
21 - /// and why they stayed hand-rolled until it closed.
22 - ///
23 - /// The value round-trips through a string because that is what the description
24 - /// carries and what every renderer of it takes: a webview reads a string out of
25 - /// the DOM and a terminal keeps an edit buffer, so a number would be this
26 - /// renderer's convenience imposed on the other two. Rebuilt per frame rather
27 - /// than held, which a slider allows and a text box does not: there is no
28 - /// half-typed state to lose.
29 - fn threshold_field(
30 - ui: &mut egui::Ui,
31 - name: &str,
32 - label: &str,
33 - value: &mut f32,
34 - ) -> Option<egui::Response> {
35 - let described = makeover_layout::Field {
36 - curve: makeover_layout::Curve::Linear {
37 - step: Some(THRESHOLD_STEP),
38 - },
39 - ..makeover_layout::Field::range(name, label, "0", "1")
40 - };
41 - let mut text = format!("{value:.2}");
42 - let response = widgets::field(
43 - ui,
44 - &described,
45 - makeover_immediate::Filling::Text(&mut text),
46 - None,
47 - );
48 - if let Ok(parsed) = text.parse::<f32>() {
49 - *value = parsed;
50 - }
51 - response
52 - }
53 -
54 - /// The granularity a confidence moves in.
55 - ///
56 - /// Two decimals, which is what the thresholds were already stored and shown at.
57 - /// Without it the host's own granularity stands, and egui's is continuous, so a
58 - /// drag would write back 0.7200000000000001 and the next comparison against a
59 - /// stored value would be against a number nobody chose.
60 - const THRESHOLD_STEP: &str = "0.01";
61 -
62 - /// Fields offered in the condition builder (path/name + tags first, then DSP).
63 - const FIELDS: &[RuleField] = &[
64 - RuleField::Name,
65 - RuleField::SourcePath,
66 - RuleField::VfsPath,
67 - RuleField::FileExtension,
68 - RuleField::Tag,
69 - RuleField::Duration,
70 - RuleField::Bpm,
71 - RuleField::MusicalKey,
72 - RuleField::IsLoop,
73 - RuleField::SpectralCentroid,
74 - RuleField::SpectralFlatness,
75 - RuleField::SpectralRolloff,
76 - RuleField::Zcr,
77 - RuleField::SpectralBandwidth,
78 - RuleField::CentroidVariance,
79 - RuleField::CrestFactor,
80 - RuleField::AttackTime,
81 - RuleField::PeakDb,
82 - RuleField::RmsDb,
83 - RuleField::Lufs,
84 - RuleField::SampleRate,
85 - RuleField::Channels,
86 - RuleField::FileSize,
87 - ];
88 -
89 - fn field_label(f: RuleField) -> &'static str {
90 - use RuleField::{
91 - AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension,
92 - FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath,
93 - SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath, Zcr,
94 - };
95 - match f {
96 - Name => "File name",
97 - SourcePath => "Source path",
98 - VfsPath => "Folder path",
99 - FileExtension => "Extension",
100 - Tag => "Tag",
101 - Duration => "Duration (s)",
102 - Bpm => "BPM",
103 - MusicalKey => "Key",
104 - IsLoop => "Is loop",
105 - SpectralCentroid => "Brightness",
106 - SpectralFlatness => "Noisiness",
107 - SpectralRolloff => "Rolloff",
108 - Zcr => "Zero-crossing rate",
109 - SpectralBandwidth => "Bandwidth",
110 - CentroidVariance => "Spectral motion",
111 - CrestFactor => "Crest factor",
112 - AttackTime => "Attack (s)",
113 - PeakDb => "Peak dB",
114 - RmsDb => "RMS dB",
115 - Lufs => "LUFS",
116 - SampleRate => "Sample rate",
117 - Channels => "Channels",
118 - FileSize => "File size",
119 - }
120 - }
121 -
122 - enum Kind {
123 - Text,
124 - Number,
125 - Bool,
126 - }
127 -
128 - fn field_kind(f: RuleField) -> Kind {
129 - use RuleField::{FileExtension, IsLoop, MusicalKey, Name, SourcePath, Tag, VfsPath};
130 - match f {
131 - Name | SourcePath | VfsPath | FileExtension | Tag | MusicalKey => Kind::Text,
132 - IsLoop => Kind::Bool,
133 - _ => Kind::Number,
134 - }
135 - }
136 -
137 - const STRING_OPS: &[RuleOp] = &[
138 - RuleOp::Contains,
139 - RuleOp::NotContains,
140 - RuleOp::Equals,
141 - RuleOp::NotEquals,
142 - RuleOp::StartsWith,
143 - RuleOp::EndsWith,
144 - RuleOp::Exists,
145 - RuleOp::NotExists,
146 - ];
147 - const NUM_OPS: &[RuleOp] = &[
148 - RuleOp::Lt,
149 - RuleOp::Le,
150 - RuleOp::Gt,
151 - RuleOp::Ge,
152 - RuleOp::Equals,
153 - RuleOp::NotEquals,
154 - RuleOp::Exists,
155 - RuleOp::NotExists,
156 - ];
157 - const BOOL_OPS: &[RuleOp] = &[
158 - RuleOp::IsTrue,
159 - RuleOp::IsFalse,
160 - RuleOp::Exists,
161 - RuleOp::NotExists,
162 - ];
163 -
164 - fn ops_for(f: RuleField) -> &'static [RuleOp] {
165 - match field_kind(f) {
166 - Kind::Text => STRING_OPS,
167 - Kind::Number => NUM_OPS,
168 - Kind::Bool => BOOL_OPS,
169 - }
170 - }
171 -
172 - fn op_label(op: RuleOp) -> &'static str {
173 - use RuleOp::{
174 - Contains, EndsWith, Equals, Exists, Ge, Gt, IsFalse, IsTrue, Le, Lt, NotContains,
175 - NotEquals, NotExists, StartsWith,
176 - };
177 - match op {
178 - Contains => "contains",
179 - NotContains => "doesn't contain",
180 - Equals => "equals",
181 - NotEquals => "not equals",
182 - StartsWith => "starts with",
183 - EndsWith => "ends with",
184 - Lt => "<",
185 - Le => "\u{2264}",
186 - Gt => ">",
187 - Ge => "\u{2265}",
188 - IsTrue => "is true",
189 - IsFalse => "is false",
190 - Exists => "exists",
191 - NotExists => "missing",
192 - }
193 - }
194 -
195 - fn op_needs_value(op: RuleOp) -> bool {
196 - !matches!(
197 - op,
198 - RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse
199 - )
200 - }
201 -
202 - /// Keep a condition's operator valid for its field's kind: if `op` isn't one the
203 - /// field offers, fall back to the field's first offered op. `ops_for` is never empty,
204 - /// so `ops[0]` always exists.
205 - fn coerce_op(field: RuleField, op: RuleOp) -> RuleOp {
206 - let ops = ops_for(field);
207 - if ops.contains(&op) { op } else { ops[0] }
208 - }
209 -
210 - /// Draw all tag-classifier sections in Settings.
211 - pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
212 - // A background job (train/auto-tag/cluster/export) is running: show progress and let
213 - // the disabled buttons below explain why they're inert. Footer status is hidden behind
214 - // the Settings modal, so feedback lives here.
215 - if let Some(label) = state.classifier.busy.clone() {
216 - ui.horizontal(|ui| {
217 - ui.add(egui::Spinner::new().size(14.0));
218 - ui.label(
219 - egui::RichText::new(label)
220 - .small()
221 - .color(theme::content_secondary()),
222 - );
223 - });
224 - ui.add_space(theme::space::bound());
225 - } else if let Some(err) = state.classifier.last_error.clone() {
226 - widgets::warning_banner(ui, &err);
227 - ui.add_space(theme::space::bound());
228 - }
229 -
230 - draw_rules_header(ui, state);
231 - ui.add_space(theme::space::bound());
232 - draw_autotag_header(ui, state);
233 - ui.add_space(theme::space::bound());
234 - draw_cluster_header(ui, state);
235 - ui.add_space(theme::space::bound());
236 - draw_folder_header(ui, state);
237 - ui.add_space(theme::space::bound());
238 - draw_sharing_header(ui, state);
239 - }
240 -
241 - /// "Tag Rules" section (Layer A).
242 - fn draw_rules_header(ui: &mut egui::Ui, state: &mut BrowserState) {
243 - egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong())
244 - .default_open(false)
245 - .show(ui, |ui| {
246 - state.ensure_rules_loaded();
247 - ui.label(
248 - egui::RichText::new(
249 - "Deterministic rules that auto-apply tags by sample metadata and audio \
250 - features. Rules never remove tags you added by hand.",
251 - )
252 - .small()
253 - .color(theme::content_muted()),
254 - );
255 - ui.add_space(theme::space::bound());
256 -
257 - if state.classifier.editing.is_some() {
258 - draw_editor(ui, state);
259 - } else {
260 - draw_list(ui, state);
261 - }
262 - });
263 - }
264 -
265 - fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) {
266 - ui.horizontal(|ui| {
267 - if ui.button("New rule").clicked() {
268 - state.classifier_new_draft();
269 - }
270 - if ui
271 - .button("Apply rules now")
272 - .on_hover_text("Re-evaluate every rule across the whole library")
273 - .clicked()
274 - {
275 - state.classifier_apply_all();
276 - }
277 - if ui
278 - .button("Add starter rules")
279 - .on_hover_text(
280 - "Add filename rules for the common instrument and format words \
281 - (Kick.wav -> instrument.drum.kick). They arrive disabled: review \
282 - them, then enable the ones you want.",
283 - )
284 - .clicked()
285 - {
286 - state.classifier_seed_starter_rules();
287 - }
288 - });
289 -
290 - if let Some(n) = state.classifier.last_apply {
291 - ui.label(
292 - egui::RichText::new(format!(
293 - "Last apply: {n} sample{} updated",
294 - if n == 1 { "" } else { "s" }
295 - ))
296 - .small()
297 - .color(theme::content_muted()),
298 - );
299 - }
300 - ui.add_space(theme::space::bound());
301 -
302 - if state.classifier.rules.is_empty() {
303 - ui.label(
304 - egui::RichText::new(
305 - "No rules yet \u{2014} new rules start empty; you decide what gets tagged.",
306 - )
307 - .small()
308 - .color(theme::content_muted()),
309 - );
310 - return;
311 - }
312 -
313 - // Move the list out to iterate it, then move it back before applying the
314 - // deferred actions (a zero-copy swap replacing a per-frame deep clone of the
315 - // whole rules Vec). The loop body reads only the local list + `ui`, never
316 - // `state.classifier.rules`, so the temporary empty field is never observed.
317 - let rules = std::mem::take(&mut state.classifier.rules);
318 - let count = rules.len();
319 - let mut toggle: Option<(String, bool)> = None;
320 - let mut edit: Option<usize> = None;
321 - let mut delete: Option<String> = None;
322 - let mut move_rule: Option<(usize, bool)> = None;
323 -
324 - for (i, rule) in rules.iter().enumerate() {
325 - ui.horizontal(|ui| {
326 - let mut enabled = rule.enabled;
327 - if ui.checkbox(&mut enabled, "").changed() {
328 - toggle = Some((rule.id.clone(), enabled));
329 - }
330 - let name = if rule.name.trim().is_empty() {
331 - "(unnamed)"
332 - } else {
333 - rule.name.as_str()
334 - };
335 - ui.label(name);
336 - ui.label(
337 - egui::RichText::new(format!(
338 - "{} cond \u{2192} {} act",
339 - rule.conditions.len(),
340 - rule.actions.len()
341 - ))
342 - .small()
343 - .color(theme::content_muted()),
344 - );
345 -
346 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
347 - if widgets::danger_small_button(ui, "Delete").clicked() {
348 - delete = Some(rule.id.clone());
349 - }
350 - if ui.small_button("Edit").clicked() {
351 - edit = Some(i);
352 - }
353 - if ui
354 - .add_enabled(i + 1 < count, egui::Button::new("\u{25bc}").small())
355 - .clicked()
356 - {
357 - move_rule = Some((i, false));
358 - }
359 - if ui
360 - .add_enabled(i > 0, egui::Button::new("\u{25b2}").small())
361 - .clicked()
362 - {
363 - move_rule = Some((i, true));
364 - }
365 - });
366 - });
367 - }
368 -
369 - // Restore the list before applying deferred actions (which mutate it).
370 - state.classifier.rules = rules;
371 -
372 - if let Some((id, enabled)) = toggle {
373 - state.classifier_toggle_rule(&id, enabled);
374 - }
375 - if let Some(i) = edit {
376 - let rule = state.classifier.rules[i].clone();
377 - state.classifier_edit_rule(&rule);
378 - }
379 - if let Some(id) = delete {
380 - state.classifier_delete_rule(&id);
381 - }
382 - if let Some((i, up)) = move_rule {
383 - state.classifier_move_rule(i, up);
384 - }
385 - }
386 -
387 - fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) {
388 - let mut do_save = false;
389 - let mut do_cancel = false;
390 - let mut do_test = false;
391 -
392 - {
393 - let Some(draft) = state.classifier.editing.as_mut() else {
394 - return;
395 - };
396 -
397 - // A described field: a labelled question the form refuses to submit
398 - // without, and the one input here that had no well at all — a bare
399 - // `text_edit_singleline` next to every other text input in the app
400 - // being a well. The error is `save_refused` rather than "empty" so a
401 - // freshly opened editor is not already complaining.
402 - let name_field = makeover_layout::Field {
403 - required: true,
404 - error: (draft.save_refused && draft.name.trim().is_empty())
405 - .then_some("Name the rule before saving."),
406 - placeholder: Some("Kick drums"),
407 - ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "rule_name", "Name")
408 - };
409 - if widgets::field(
410 - ui,
411 - &name_field,
412 - makeover_immediate::Filling::Text(&mut draft.name),
413 - None,
414 - )
415 - .is_some_and(|response| response.changed())
416 - {
417 - draft.save_refused = false;
418 - }
419 - ui.add_space(theme::space::bound());
420 - // A bare toggle, not a field: no help, no validation, no options, and
421 - // under `field()` it would be a checkbox plus a required marker at
422 - // three times the source.
423 - ui.checkbox(&mut draft.enabled, "Enabled");
424 -
425 - ui.horizontal(|ui| {
426 - ui.label("Match");
427 - ui.selectable_value(&mut draft.match_mode, MatchMode::All, "All");
428 - ui.selectable_value(&mut draft.match_mode, MatchMode::Any, "Any");
429 - ui.label(
430 - egui::RichText::new("of these conditions")
431 - .small()
432 - .color(theme::content_muted()),
433 - );
434 - });
435 -
436 - // --- Conditions ---
437 - widgets::subsection_label(ui, "When");
438 - let mut remove_cond: Option<usize> = None;
439 - let cond_count = draft.conditions.len();
440 - for (i, cond) in draft.conditions.iter_mut().enumerate() {
441 - ui.horizontal(|ui| {
442 - egui::ComboBox::from_id_salt(("rule_field", i))
443 - .selected_text(field_label(cond.field))
444 - .width(120.0)
445 - .show_ui(ui, |ui| {
446 - for f in FIELDS {
447 - ui.selectable_value(&mut cond.field, *f, field_label(*f));
448 - }
449 - });
450 - // Keep the operator valid for the chosen field's kind.
451 - cond.op = coerce_op(cond.field, cond.op);
452 - let ops = ops_for(cond.field);
453 - egui::ComboBox::from_id_salt(("rule_op", i))
454 - .selected_text(op_label(cond.op))
455 - .width(110.0)
456 - .show_ui(ui, |ui| {
457 - for op in ops {
458 - ui.selectable_value(&mut cond.op, *op, op_label(*op));
459 - }
460 - });
461 - if op_needs_value(cond.op) {
462 - widgets::text_field(
463 - ui,
464 - egui::TextEdit::singleline(&mut cond.value)
465 - .desired_width(70.0)
466 - .hint_text("value"),
467 - );
468 - }
469 - if ui
470 - .add_enabled(cond_count > 1, egui::Button::new("\u{2715}").small())
471 - .clicked()
472 - {
473 - remove_cond = Some(i);
474 - }
475 - });
476 - }
477 - if let Some(i) = remove_cond {
478 - draft.conditions.remove(i);
479 - draft.match_count = None;
480 - }
481 - if ui.small_button("Add condition").clicked() {
482 - draft.conditions.push(RuleCondition {
483 - field: RuleField::Name,
484 - op: RuleOp::Contains,
485 - value: String::new(),
486 - });
487 - draft.match_count = None;
488 - }
489 -
490 - // --- Actions ---
491 - ui.add_space(theme::space::bound());
492 - widgets::subsection_label(ui, "Then");
493 - let mut remove_act: Option<usize> = None;
494 - for (i, action) in draft.actions.iter_mut().enumerate() {
495 - ui.horizontal(|ui| {
496 - // 0 = add tag, 1 = remove tag, 2 = stop.
497 - let mut kind = match action {
498 - RuleAction::AddTag(_) => 0u8,
499 - RuleAction::RemoveTag(_) => 1,
500 - RuleAction::Stop => 2,
Lines truncated