max / audiofiles
12 files changed,
+799 insertions,
-2 deletions
| @@ -581,6 +581,52 @@ impl Backend for DirectBackend { | |||
| 581 | 581 | Ok(audiofiles_core::analysis::samples_needing_features(&db)?) | |
| 582 | 582 | } | |
| 583 | 583 | ||
| 584 | + | fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>> { | |
| 585 | + | let db = self.db.lock(); | |
| 586 | + | Ok(audiofiles_core::rules::list_rules(&db)?) | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | fn create_rule( | |
| 590 | + | &self, | |
| 591 | + | rule: audiofiles_core::rules::NewRule, | |
| 592 | + | ) -> BackendResult<audiofiles_core::rules::Rule> { | |
| 593 | + | let db = self.db.lock(); | |
| 594 | + | Ok(audiofiles_core::rules::create_rule(&db, rule)?) | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | fn update_rule(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<()> { | |
| 598 | + | let db = self.db.lock(); | |
| 599 | + | Ok(audiofiles_core::rules::update_rule(&db, rule)?) | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | fn delete_rule(&self, id: &str) -> BackendResult<()> { | |
| 603 | + | let db = self.db.lock(); | |
| 604 | + | Ok(audiofiles_core::rules::delete_rule(&db, id)?) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | fn set_rule_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> { | |
| 608 | + | let db = self.db.lock(); | |
| 609 | + | Ok(audiofiles_core::rules::set_rule_enabled(&db, id, enabled)?) | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | fn preview_rule_matches(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<usize> { | |
| 613 | + | let db = self.db.lock(); | |
| 614 | + | Ok(audiofiles_core::rules::preview_rule_matches(&db, rule)?) | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | fn apply_all_rules(&self) -> BackendResult<usize> { | |
| 618 | + | let db = self.db.lock(); | |
| 619 | + | Ok(audiofiles_core::rules::apply_all_rules(&db)?) | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | fn sample_tag_provenance( | |
| 623 | + | &self, | |
| 624 | + | hash: &str, | |
| 625 | + | ) -> BackendResult<Vec<(String, String, Option<String>)>> { | |
| 626 | + | let db = self.db.lock(); | |
| 627 | + | Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?) | |
| 628 | + | } | |
| 629 | + | ||
| 584 | 630 | fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> { | |
| 585 | 631 | let db = self.db.lock(); | |
| 586 | 632 | Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash)) |
| @@ -354,6 +354,39 @@ pub trait Backend: Send + Sync { | |||
| 354 | 354 | /// (the feature-backfill work-list). | |
| 355 | 355 | fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>>; | |
| 356 | 356 | ||
| 357 | + | // --- Tag rules (classifier Layer A) --- | |
| 358 | + | ||
| 359 | + | /// All tag rules in evaluation (priority) order. | |
| 360 | + | fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>>; | |
| 361 | + | ||
| 362 | + | /// Create a rule; returns it with its assigned id. | |
| 363 | + | fn create_rule( | |
| 364 | + | &self, | |
| 365 | + | rule: audiofiles_core::rules::NewRule, | |
| 366 | + | ) -> BackendResult<audiofiles_core::rules::Rule>; | |
| 367 | + | ||
| 368 | + | /// Replace an existing rule (matched by id). | |
| 369 | + | fn update_rule(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<()>; | |
| 370 | + | ||
| 371 | + | /// Delete a rule and reconcile the samples it had tagged. | |
| 372 | + | fn delete_rule(&self, id: &str) -> BackendResult<()>; | |
| 373 | + | ||
| 374 | + | /// Toggle a rule's enabled flag. | |
| 375 | + | fn set_rule_enabled(&self, id: &str, enabled: bool) -> BackendResult<()>; | |
| 376 | + | ||
| 377 | + | /// Dry-run: how many samples a rule's conditions match. | |
| 378 | + | fn preview_rule_matches(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<usize>; | |
| 379 | + | ||
| 380 | + | /// Re-apply all rules across the library; returns the number of samples changed. | |
| 381 | + | fn apply_all_rules(&self) -> BackendResult<usize>; | |
| 382 | + | ||
| 383 | + | /// Provenance of each machine-applied tag on a sample: `(tag, source, rule_id)`. | |
| 384 | + | /// Tags absent here (but present on the sample) are manual. | |
| 385 | + | fn sample_tag_provenance( | |
| 386 | + | &self, | |
| 387 | + | hash: &str, | |
| 388 | + | ) -> BackendResult<Vec<(String, String, Option<String>)>>; | |
| 389 | + | ||
| 357 | 390 | /// Get waveform display data for a sample. | |
| 358 | 391 | fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>; | |
| 359 | 392 |
| @@ -0,0 +1,182 @@ | |||
| 1 | + | //! Tag-classifier actions (Layer A rules builder) on `BrowserState`. | |
| 2 | + | //! | |
| 3 | + | //! The UI (`ui/classifier.rs`) reads `state.classifier` and calls these methods; they | |
| 4 | + | //! wrap the backend rule operations and keep the cached rule list fresh. | |
| 5 | + | ||
| 6 | + | use audiofiles_core::rules::{MatchMode, NewRule, Rule, RuleAction, RuleCondition, RuleField, RuleOp}; | |
| 7 | + | ||
| 8 | + | use super::{BrowserState, RuleDraft}; | |
| 9 | + | ||
| 10 | + | impl BrowserState { | |
| 11 | + | /// Reload the cached rule list from the backend. | |
| 12 | + | pub fn refresh_rules(&mut self) { | |
| 13 | + | self.classifier.rules = self.backend.list_rules().unwrap_or_default(); | |
| 14 | + | self.classifier.loaded = true; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Ensure rules are loaded at least once (called when the section opens). | |
| 18 | + | pub fn ensure_rules_loaded(&mut self) { | |
| 19 | + | if !self.classifier.loaded { | |
| 20 | + | self.refresh_rules(); | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Begin authoring a new rule (one empty condition, one add-tag action). | |
| 25 | + | pub fn classifier_new_draft(&mut self) { | |
| 26 | + | self.classifier.editing = Some(RuleDraft { | |
| 27 | + | id: None, | |
| 28 | + | name: String::new(), | |
| 29 | + | enabled: true, | |
| 30 | + | match_mode: MatchMode::All, | |
| 31 | + | conditions: vec![RuleCondition { | |
| 32 | + | field: RuleField::Name, | |
| 33 | + | op: RuleOp::Contains, | |
| 34 | + | value: String::new(), | |
| 35 | + | }], | |
| 36 | + | actions: vec![RuleAction::AddTag(String::new())], | |
| 37 | + | priority: 0, | |
| 38 | + | created_at: 0, | |
| 39 | + | match_count: None, | |
| 40 | + | }); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Begin editing an existing rule. | |
| 44 | + | pub fn classifier_edit_rule(&mut self, rule: &Rule) { | |
| 45 | + | self.classifier.editing = Some(RuleDraft { | |
| 46 | + | id: Some(rule.id.clone()), | |
| 47 | + | name: rule.name.clone(), | |
| 48 | + | enabled: rule.enabled, | |
| 49 | + | match_mode: rule.match_mode, | |
| 50 | + | conditions: rule.conditions.clone(), | |
| 51 | + | actions: rule.actions.clone(), | |
| 52 | + | priority: rule.priority, | |
| 53 | + | created_at: rule.created_at, | |
| 54 | + | match_count: None, | |
| 55 | + | }); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Discard the in-progress draft. | |
| 59 | + | pub fn classifier_cancel_draft(&mut self) { | |
| 60 | + | self.classifier.editing = None; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Build a `Rule` from the current draft (placeholder id/priority for new rules — | |
| 64 | + | /// fine for dry-run, which only reads conditions + match_mode). | |
| 65 | + | fn draft_as_rule(draft: &RuleDraft) -> Rule { | |
| 66 | + | Rule { | |
| 67 | + | id: draft.id.clone().unwrap_or_default(), | |
| 68 | + | name: draft.name.clone(), | |
| 69 | + | enabled: draft.enabled, | |
| 70 | + | priority: draft.priority, | |
| 71 | + | match_mode: draft.match_mode, | |
| 72 | + | conditions: draft.conditions.clone(), | |
| 73 | + | actions: draft.actions.clone(), | |
| 74 | + | created_at: draft.created_at, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Dry-run the current draft against the library, caching the match count. | |
| 79 | + | pub fn classifier_test_draft(&mut self) { | |
| 80 | + | let Some(draft) = self.classifier.editing.as_ref() else { return }; | |
| 81 | + | let rule = Self::draft_as_rule(draft); | |
| 82 | + | match self.backend.preview_rule_matches(&rule) { | |
| 83 | + | Ok(n) => { | |
| 84 | + | if let Some(d) = self.classifier.editing.as_mut() { | |
| 85 | + | d.match_count = Some(n); | |
| 86 | + | } | |
| 87 | + | self.status = format!("Rule matches {n} sample{}", if n == 1 { "" } else { "s" }); | |
| 88 | + | } | |
| 89 | + | Err(e) => self.status = format!("Rule test failed: {e}"), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Persist the current draft (create or update), then reconcile + refresh. | |
| 94 | + | pub fn classifier_save_draft(&mut self) { | |
| 95 | + | let Some(draft) = self.classifier.editing.clone() else { return }; | |
| 96 | + | if draft.name.trim().is_empty() { | |
| 97 | + | self.status = "Name the rule before saving.".to_string(); | |
| 98 | + | return; | |
| 99 | + | } | |
| 100 | + | let result = match draft.id { | |
| 101 | + | None => self | |
| 102 | + | .backend | |
| 103 | + | .create_rule(NewRule { | |
| 104 | + | name: draft.name.trim().to_string(), | |
| 105 | + | enabled: draft.enabled, | |
| 106 | + | priority: None, | |
| 107 | + | match_mode: draft.match_mode, | |
| 108 | + | conditions: draft.conditions.clone(), | |
| 109 | + | actions: draft.actions.clone(), | |
| 110 | + | }) | |
| 111 | + | .map(|_| ()), | |
| 112 | + | Some(_) => self.backend.update_rule(&Self::draft_as_rule(&draft)), | |
| 113 | + | }; | |
| 114 | + | match result { | |
| 115 | + | Ok(()) => { | |
| 116 | + | self.classifier.editing = None; | |
| 117 | + | self.refresh_rules(); | |
| 118 | + | // Apply immediately so the rule's effect is visible. | |
| 119 | + | self.classifier_apply_all(); | |
| 120 | + | } | |
| 121 | + | Err(e) => self.status = format!("Could not save rule: {e}"), | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// Delete a rule (reconciling its tags away) and refresh. | |
| 126 | + | pub fn classifier_delete_rule(&mut self, id: &str) { | |
| 127 | + | match self.backend.delete_rule(id) { | |
| 128 | + | Ok(()) => { | |
| 129 | + | self.refresh_rules(); | |
| 130 | + | self.refresh_selected_tags(); | |
| 131 | + | self.status = "Rule deleted.".to_string(); | |
| 132 | + | } | |
| 133 | + | Err(e) => self.status = format!("Could not delete rule: {e}"), | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Toggle a rule's enabled flag, then re-apply so the change takes effect. | |
| 138 | + | pub fn classifier_toggle_rule(&mut self, id: &str, enabled: bool) { | |
| 139 | + | if let Err(e) = self.backend.set_rule_enabled(id, enabled) { | |
| 140 | + | self.status = format!("Could not update rule: {e}"); | |
| 141 | + | return; | |
| 142 | + | } | |
| 143 | + | self.refresh_rules(); | |
| 144 | + | self.classifier_apply_all(); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Move a rule up or down in evaluation order by swapping priorities with its | |
| 148 | + | /// neighbor. `idx` is the position in the (priority-ordered) cached list. | |
| 149 | + | pub fn classifier_move_rule(&mut self, idx: usize, up: bool) { | |
| 150 | + | let rules = &self.classifier.rules; | |
| 151 | + | let other = if up { | |
| 152 | + | if idx == 0 { | |
| 153 | + | return; | |
| 154 | + | } | |
| 155 | + | idx - 1 | |
| 156 | + | } else { | |
| 157 | + | if idx + 1 >= rules.len() { | |
| 158 | + | return; | |
| 159 | + | } | |
| 160 | + | idx + 1 | |
| 161 | + | }; | |
| 162 | + | let mut a = rules[idx].clone(); | |
| 163 | + | let mut b = rules[other].clone(); | |
| 164 | + | std::mem::swap(&mut a.priority, &mut b.priority); | |
| 165 | + | if self.backend.update_rule(&a).is_ok() && self.backend.update_rule(&b).is_ok() { | |
| 166 | + | self.refresh_rules(); | |
| 167 | + | self.classifier_apply_all(); | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Re-apply all rules across the library; records how many samples changed. | |
| 172 | + | pub fn classifier_apply_all(&mut self) { | |
| 173 | + | match self.backend.apply_all_rules() { | |
| 174 | + | Ok(n) => { | |
| 175 | + | self.classifier.last_apply = Some(n); | |
| 176 | + | self.refresh_selected_tags(); | |
| 177 | + | self.status = format!("Rules applied — {n} sample{} updated", if n == 1 { "" } else { "s" }); | |
| 178 | + | } | |
| 179 | + | Err(e) => self.status = format!("Could not apply rules: {e}"), | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } |
| @@ -31,6 +31,7 @@ use crate::import::{ImportedFolder, ImportStrategy}; | |||
| 31 | 31 | use crate::instrument::InstrumentPlayback; | |
| 32 | 32 | use crate::preview::PreviewPlayback; | |
| 33 | 33 | ||
| 34 | + | mod classifier; | |
| 34 | 35 | mod navigation; | |
| 35 | 36 | pub mod import_workflow; | |
| 36 | 37 | mod bulk_ops; | |
| @@ -99,6 +100,9 @@ pub struct BrowserState { | |||
| 99 | 100 | pub contents: Arc<Vec<VfsNodeWithAnalysis>>, | |
| 100 | 101 | pub selection: Selection, | |
| 101 | 102 | pub selected_tags: Arc<Vec<String>>, | |
| 103 | + | /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag | |
| 104 | + | /// absent from this map is manual. Refreshed alongside `selected_tags`. | |
| 105 | + | pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>, | |
| 102 | 106 | pub status: String, | |
| 103 | 107 | /// When the current `status` message was posted. Drives the footer's | |
| 104 | 108 | /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means | |
| @@ -317,6 +321,9 @@ pub struct BrowserState { | |||
| 317 | 321 | // Settings (consolidated window) | |
| 318 | 322 | pub settings: SettingsUiState, | |
| 319 | 323 | ||
| 324 | + | /// Tag-classifier UI state (rules builder in Settings). | |
| 325 | + | pub classifier: ClassifierUiState, | |
| 326 | + | ||
| 320 | 327 | // Loose-files mode integrity | |
| 321 | 328 | /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files). | |
| 322 | 329 | pub loose_files_missing_count: usize, | |
| @@ -454,6 +461,7 @@ impl BrowserState { | |||
| 454 | 461 | contents: Arc::new(contents), | |
| 455 | 462 | selection: Selection::new(), | |
| 456 | 463 | selected_tags: Arc::new(Vec::new()), | |
| 464 | + | selected_tag_sources: std::collections::HashMap::new(), | |
| 457 | 465 | status: String::new(), | |
| 458 | 466 | status_set_at: None, | |
| 459 | 467 | selected_analysis: None, | |
| @@ -548,6 +556,7 @@ impl BrowserState { | |||
| 548 | 556 | mirror_dirty: mirror_enabled, | |
| 549 | 557 | sync: SyncUiState::default(), | |
| 550 | 558 | settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() }, | |
| 559 | + | classifier: ClassifierUiState::default(), | |
| 551 | 560 | loose_files_missing_count: 0, | |
| 552 | 561 | show_loose_files_warning: false, | |
| 553 | 562 | }) |
| @@ -118,12 +118,20 @@ impl BrowserState { | |||
| 118 | 118 | /// Reload the tag list for the currently focused sample (shown in the detail panel). | |
| 119 | 119 | pub fn refresh_selected_tags(&mut self) { | |
| 120 | 120 | self.selected_tags = Arc::new(Vec::new()); | |
| 121 | + | self.selected_tag_sources.clear(); | |
| 121 | 122 | if let Some(node) = self.selected_node() | |
| 122 | 123 | && let Some(hash) = &node.node.sample_hash { | |
| 123 | - | self.selected_tags = Arc::new(self.backend.get_sample_tags(hash).unwrap_or_else(|e| { | |
| 124 | + | let hash = hash.clone(); | |
| 125 | + | self.selected_tags = Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_else(|e| { | |
| 124 | 126 | warn!("Failed to load tags: {e}"); | |
| 125 | 127 | Vec::new() | |
| 126 | 128 | })); | |
| 129 | + | if let Ok(prov) = self.backend.sample_tag_provenance(&hash) { | |
| 130 | + | self.selected_tag_sources = prov | |
| 131 | + | .into_iter() | |
| 132 | + | .map(|(tag, source, rule_id)| (tag, (source, rule_id))) | |
| 133 | + | .collect(); | |
| 134 | + | } | |
| 127 | 135 | } | |
| 128 | 136 | } | |
| 129 | 137 |
| @@ -1839,4 +1839,37 @@ mod misc { | |||
| 1839 | 1839 | // No rules configured -> no change. | |
| 1840 | 1840 | assert!(!state.backend.apply_rules_to_sample("aaa111").unwrap()); | |
| 1841 | 1841 | } | |
| 1842 | + | ||
| 1843 | + | #[test] | |
| 1844 | + | fn classifier_create_rule_applies_tag() { | |
| 1845 | + | let (mut state, _dir) = make_state(); | |
| 1846 | + | insert_fake_sample(&state, "aaa111"); | |
| 1847 | + | state.refresh_rules(); | |
| 1848 | + | ||
| 1849 | + | // Author an unconditional rule that adds a tag, via the UI action methods. | |
| 1850 | + | state.classifier_new_draft(); | |
| 1851 | + | { | |
| 1852 | + | let d = state.classifier.editing.as_mut().unwrap(); | |
| 1853 | + | d.name = "tag everything".to_string(); | |
| 1854 | + | d.conditions.clear(); // empty conditions => matches all samples | |
| 1855 | + | d.actions = vec![audiofiles_core::rules::RuleAction::AddTag("auto.tagged".to_string())]; | |
| 1856 | + | } | |
| 1857 | + | state.classifier_save_draft(); | |
| 1858 | + | ||
| 1859 | + | // Rule persisted and the editor closed. | |
| 1860 | + | assert!(state.classifier.editing.is_none()); | |
| 1861 | + | assert_eq!(state.classifier.rules.len(), 1); | |
| 1862 | + | ||
| 1863 | + | // Tag applied to the sample with 'rule' provenance. | |
| 1864 | + | let tags = state.backend.get_sample_tags("aaa111").unwrap(); | |
| 1865 | + | assert!(tags.contains(&"auto.tagged".to_string())); | |
| 1866 | + | let prov = state.backend.sample_tag_provenance("aaa111").unwrap(); | |
| 1867 | + | assert!(prov.iter().any(|(t, s, _)| t == "auto.tagged" && s == "rule")); | |
| 1868 | + | ||
| 1869 | + | // Deleting the rule reconciles the tag away. | |
| 1870 | + | let id = state.classifier.rules[0].id.clone(); | |
| 1871 | + | state.classifier_delete_rule(&id); | |
| 1872 | + | assert!(state.classifier.rules.is_empty()); | |
| 1873 | + | assert!(!state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.tagged".to_string())); | |
| 1874 | + | } | |
| 1842 | 1875 | } |
| @@ -279,6 +279,37 @@ pub struct SettingsUiState { | |||
| 279 | 279 | pub trial_days_remaining: Option<i64>, | |
| 280 | 280 | } | |
| 281 | 281 | ||
| 282 | + | /// GUI state for the tag-classifier sections in Settings (Layer A rules builder). | |
| 283 | + | #[derive(Default)] | |
| 284 | + | pub struct ClassifierUiState { | |
| 285 | + | /// Cached rules, refreshed when the section opens and after edits. | |
| 286 | + | pub rules: Vec<audiofiles_core::rules::Rule>, | |
| 287 | + | /// True once `rules` has been loaded at least once this session. | |
| 288 | + | pub loaded: bool, | |
| 289 | + | /// The rule currently being created/edited (None = list view). | |
| 290 | + | pub editing: Option<RuleDraft>, | |
| 291 | + | /// Number of samples changed by the last "Apply rules now" run. | |
| 292 | + | pub last_apply: Option<usize>, | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule` | |
| 296 | + | /// minus the persisted id/created_at (kept in `id` when editing an existing rule). | |
| 297 | + | #[derive(Default, Clone)] | |
| 298 | + | pub struct RuleDraft { | |
| 299 | + | /// Existing rule id, or `None` for a new rule. | |
| 300 | + | pub id: Option<String>, | |
| 301 | + | pub name: String, | |
| 302 | + | pub enabled: bool, | |
| 303 | + | pub match_mode: audiofiles_core::rules::MatchMode, | |
| 304 | + | pub conditions: Vec<audiofiles_core::rules::RuleCondition>, | |
| 305 | + | pub actions: Vec<audiofiles_core::rules::RuleAction>, | |
| 306 | + | /// Original priority/created_at, carried through so editing preserves order. | |
| 307 | + | pub priority: i64, | |
| 308 | + | pub created_at: i64, | |
| 309 | + | /// Cached dry-run match count (recomputed when the draft changes). | |
| 310 | + | pub match_count: Option<usize>, | |
| 311 | + | } | |
| 312 | + | ||
| 282 | 313 | /// GUI state for the sync setup and panel. | |
| 283 | 314 | pub struct SyncUiState { | |
| 284 | 315 | /// Whether the sync panel overlay is open. |
| @@ -0,0 +1,422 @@ | |||
| 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 | + | ||
| 7 | + | use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp}; | |
| 8 | + | ||
| 9 | + | use super::theme; | |
| 10 | + | use super::widgets; | |
| 11 | + | use crate::state::BrowserState; | |
| 12 | + | ||
| 13 | + | /// Fields offered in the condition builder (path/name + tags first, then DSP). | |
| 14 | + | const FIELDS: &[RuleField] = &[ | |
| 15 | + | RuleField::Name, | |
| 16 | + | RuleField::SourcePath, | |
| 17 | + | RuleField::VfsPath, | |
| 18 | + | RuleField::FileExtension, | |
| 19 | + | RuleField::Tag, | |
| 20 | + | RuleField::Duration, | |
| 21 | + | RuleField::Bpm, | |
| 22 | + | RuleField::MusicalKey, | |
| 23 | + | RuleField::IsLoop, | |
| 24 | + | RuleField::SpectralCentroid, | |
| 25 | + | RuleField::SpectralFlatness, | |
| 26 | + | RuleField::SpectralRolloff, | |
| 27 | + | RuleField::Zcr, | |
| 28 | + | RuleField::SpectralBandwidth, | |
| 29 | + | RuleField::CentroidVariance, | |
| 30 | + | RuleField::CrestFactor, | |
| 31 | + | RuleField::AttackTime, | |
| 32 | + | RuleField::PeakDb, | |
| 33 | + | RuleField::RmsDb, | |
| 34 | + | RuleField::Lufs, | |
| 35 | + | RuleField::SampleRate, | |
| 36 | + | RuleField::Channels, | |
| 37 | + | RuleField::FileSize, | |
| 38 | + | ]; | |
| 39 | + | ||
| 40 | + | fn field_label(f: RuleField) -> &'static str { | |
| 41 | + | use RuleField::*; | |
| 42 | + | match f { | |
| 43 | + | Name => "File name", | |
| 44 | + | SourcePath => "Source path", | |
| 45 | + | VfsPath => "Folder path", | |
| 46 | + | FileExtension => "Extension", | |
| 47 | + | Tag => "Tag", | |
| 48 | + | Duration => "Duration (s)", | |
| 49 | + | Bpm => "BPM", | |
| 50 | + | MusicalKey => "Key", | |
| 51 | + | IsLoop => "Is loop", | |
| 52 | + | SpectralCentroid => "Brightness", | |
| 53 | + | SpectralFlatness => "Noisiness", | |
| 54 | + | SpectralRolloff => "Rolloff", | |
| 55 | + | Zcr => "Zero-crossing rate", | |
| 56 | + | SpectralBandwidth => "Bandwidth", | |
| 57 | + | CentroidVariance => "Spectral motion", | |
| 58 | + | CrestFactor => "Crest factor", | |
| 59 | + | AttackTime => "Attack (s)", | |
| 60 | + | PeakDb => "Peak dB", | |
| 61 | + | RmsDb => "RMS dB", | |
| 62 | + | Lufs => "LUFS", | |
| 63 | + | SampleRate => "Sample rate", | |
| 64 | + | Channels => "Channels", | |
| 65 | + | FileSize => "File size", | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | enum Kind { | |
| 70 | + | Text, | |
| 71 | + | Number, | |
| 72 | + | Bool, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | fn field_kind(f: RuleField) -> Kind { | |
| 76 | + | use RuleField::*; | |
| 77 | + | match f { | |
| 78 | + | Name | SourcePath | VfsPath | FileExtension | Tag | MusicalKey => Kind::Text, | |
| 79 | + | IsLoop => Kind::Bool, | |
| 80 | + | _ => Kind::Number, | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | const STRING_OPS: &[RuleOp] = &[ | |
| 85 | + | RuleOp::Contains, | |
| 86 | + | RuleOp::NotContains, | |
| 87 | + | RuleOp::Equals, | |
| 88 | + | RuleOp::NotEquals, | |
| 89 | + | RuleOp::StartsWith, | |
| 90 | + | RuleOp::EndsWith, | |
| 91 | + | RuleOp::Exists, | |
| 92 | + | RuleOp::NotExists, | |
| 93 | + | ]; | |
| 94 | + | const NUM_OPS: &[RuleOp] = &[ | |
| 95 | + | RuleOp::Lt, | |
| 96 | + | RuleOp::Le, | |
| 97 | + | RuleOp::Gt, | |
| 98 | + | RuleOp::Ge, | |
| 99 | + | RuleOp::Equals, | |
| 100 | + | RuleOp::NotEquals, | |
| 101 | + | RuleOp::Exists, | |
| 102 | + | RuleOp::NotExists, | |
| 103 | + | ]; | |
| 104 | + | const BOOL_OPS: &[RuleOp] = &[RuleOp::IsTrue, RuleOp::IsFalse, RuleOp::Exists, RuleOp::NotExists]; | |
| 105 | + | ||
| 106 | + | fn ops_for(f: RuleField) -> &'static [RuleOp] { | |
| 107 | + | match field_kind(f) { | |
| 108 | + | Kind::Text => STRING_OPS, | |
| 109 | + | Kind::Number => NUM_OPS, | |
| 110 | + | Kind::Bool => BOOL_OPS, | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | fn op_label(op: RuleOp) -> &'static str { | |
| 115 | + | use RuleOp::*; | |
| 116 | + | match op { | |
| 117 | + | Contains => "contains", | |
| 118 | + | NotContains => "doesn't contain", | |
| 119 | + | Equals => "equals", | |
| 120 | + | NotEquals => "not equals", | |
| 121 | + | StartsWith => "starts with", | |
| 122 | + | EndsWith => "ends with", | |
| 123 | + | Lt => "<", | |
| 124 | + | Le => "\u{2264}", | |
| 125 | + | Gt => ">", | |
| 126 | + | Ge => "\u{2265}", | |
| 127 | + | IsTrue => "is true", | |
| 128 | + | IsFalse => "is false", | |
| 129 | + | Exists => "exists", | |
| 130 | + | NotExists => "missing", | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | fn op_needs_value(op: RuleOp) -> bool { | |
| 135 | + | !matches!(op, RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Draw the "Tag Rules" Settings section. | |
| 139 | + | pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 140 | + | egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong()) | |
| 141 | + | .default_open(false) | |
| 142 | + | .show(ui, |ui| { | |
| 143 | + | state.ensure_rules_loaded(); | |
| 144 | + | ui.label( | |
| 145 | + | egui::RichText::new( | |
| 146 | + | "Deterministic rules that auto-apply tags by sample metadata and audio \ | |
| 147 | + | features. Rules never remove tags you added by hand.", | |
| 148 | + | ) | |
| 149 | + | .small() | |
| 150 | + | .color(theme::content_muted()), | |
| 151 | + | ); | |
| 152 | + | ui.add_space(theme::space::SM); | |
| 153 | + | ||
| 154 | + | if state.classifier.editing.is_some() { | |
| 155 | + | draw_editor(ui, state); | |
| 156 | + | } else { | |
| 157 | + | draw_list(ui, state); | |
| 158 | + | } | |
| 159 | + | }); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 163 | + | ui.horizontal(|ui| { | |
| 164 | + | if ui.button("New rule").clicked() { | |
| 165 | + | state.classifier_new_draft(); | |
| 166 | + | } | |
| 167 | + | if ui | |
| 168 | + | .button("Apply rules now") | |
| 169 | + | .on_hover_text("Re-evaluate every rule across the whole library") | |
| 170 | + | .clicked() | |
| 171 | + | { | |
| 172 | + | state.classifier_apply_all(); | |
| 173 | + | } | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | if let Some(n) = state.classifier.last_apply { | |
| 177 | + | ui.label( | |
| 178 | + | egui::RichText::new(format!( | |
| 179 | + | "Last apply: {n} sample{} updated", | |
| 180 | + | if n == 1 { "" } else { "s" } | |
| 181 | + | )) | |
| 182 | + | .small() | |
| 183 | + | .color(theme::content_muted()), | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ui.add_space(theme::space::SM); | |
| 187 | + | ||
| 188 | + | if state.classifier.rules.is_empty() { | |
| 189 | + | ui.label( | |
| 190 | + | egui::RichText::new("No rules yet \u{2014} new rules start empty; you decide what gets tagged.") | |
| 191 | + | .small() | |
| 192 | + | .color(theme::content_muted()), | |
| 193 | + | ); | |
| 194 | + | return; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // Collect actions to apply after the immutable iteration over the cached list. | |
| 198 | + | let rules = state.classifier.rules.clone(); | |
| 199 | + | let count = rules.len(); | |
| 200 | + | let mut toggle: Option<(String, bool)> = None; | |
| 201 | + | let mut edit: Option<usize> = None; | |
| 202 | + | let mut delete: Option<String> = None; | |
| 203 | + | let mut move_rule: Option<(usize, bool)> = None; | |
| 204 | + | ||
| 205 | + | for (i, rule) in rules.iter().enumerate() { | |
| 206 | + | ui.horizontal(|ui| { | |
| 207 | + | let mut enabled = rule.enabled; | |
| 208 | + | if ui.checkbox(&mut enabled, "").changed() { | |
| 209 | + | toggle = Some((rule.id.clone(), enabled)); | |
| 210 | + | } | |
| 211 | + | let name = if rule.name.trim().is_empty() { "(unnamed)" } else { rule.name.as_str() }; | |
| 212 | + | ui.label(name); | |
| 213 | + | ui.label( | |
| 214 | + | egui::RichText::new(format!( | |
| 215 | + | "{} cond \u{2192} {} act", | |
| 216 | + | rule.conditions.len(), | |
| 217 | + | rule.actions.len() | |
| 218 | + | )) | |
| 219 | + | .small() | |
| 220 | + | .color(theme::content_muted()), | |
| 221 | + | ); | |
| 222 | + | ||
| 223 | + | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 224 | + | if widgets::danger_small_button(ui, "Delete").clicked() { | |
| 225 | + | delete = Some(rule.id.clone()); | |
| 226 | + | } | |
| 227 | + | if ui.small_button("Edit").clicked() { | |
| 228 | + | edit = Some(i); | |
| 229 | + | } | |
| 230 | + | if ui.add_enabled(i + 1 < count, egui::Button::new("\u{25bc}").small()).clicked() { | |
| 231 | + | move_rule = Some((i, false)); | |
| 232 | + | } | |
| 233 | + | if ui.add_enabled(i > 0, egui::Button::new("\u{25b2}").small()).clicked() { | |
| 234 | + | move_rule = Some((i, true)); | |
| 235 | + | } | |
| 236 | + | }); | |
| 237 | + | }); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | if let Some((id, enabled)) = toggle { | |
| 241 | + | state.classifier_toggle_rule(&id, enabled); | |
| 242 | + | } | |
| 243 | + | if let Some(i) = edit { | |
| 244 | + | let rule = rules[i].clone(); | |
| 245 | + | state.classifier_edit_rule(&rule); | |
| 246 | + | } | |
| 247 | + | if let Some(id) = delete { | |
| 248 | + | state.classifier_delete_rule(&id); | |
| 249 | + | } | |
| 250 | + | if let Some((i, up)) = move_rule { | |
| 251 | + | state.classifier_move_rule(i, up); | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 256 | + | let mut do_save = false; | |
| 257 | + | let mut do_cancel = false; | |
| 258 | + | let mut do_test = false; | |
| 259 | + | ||
| 260 | + | { | |
| 261 | + | let Some(draft) = state.classifier.editing.as_mut() else { return }; | |
| 262 | + | ||
| 263 | + | ui.horizontal(|ui| { | |
| 264 | + | ui.label("Name"); | |
| 265 | + | ui.text_edit_singleline(&mut draft.name); | |
| 266 | + | }); | |
| 267 | + | ui.checkbox(&mut draft.enabled, "Enabled"); | |
| 268 | + | ||
| 269 | + | ui.horizontal(|ui| { | |
| 270 | + | ui.label("Match"); | |
| 271 | + | ui.selectable_value(&mut draft.match_mode, MatchMode::All, "All"); | |
| 272 | + | ui.selectable_value(&mut draft.match_mode, MatchMode::Any, "Any"); | |
| 273 | + | ui.label( | |
| 274 | + | egui::RichText::new("of these conditions") | |
| 275 | + | .small() | |
| 276 | + | .color(theme::content_muted()), | |
| 277 | + | ); | |
| 278 | + | }); | |
| 279 | + | ||
| 280 | + | // --- Conditions --- | |
| 281 | + | widgets::subsection_label(ui, "When"); | |
| 282 | + | let mut remove_cond: Option<usize> = None; | |
| 283 | + | let cond_count = draft.conditions.len(); | |
| 284 | + | for (i, cond) in draft.conditions.iter_mut().enumerate() { | |
| 285 | + | ui.horizontal(|ui| { | |
| 286 | + | egui::ComboBox::from_id_salt(("rule_field", i)) | |
| 287 | + | .selected_text(field_label(cond.field)) | |
| 288 | + | .width(120.0) | |
| 289 | + | .show_ui(ui, |ui| { | |
| 290 | + | for f in FIELDS { | |
| 291 | + | ui.selectable_value(&mut cond.field, *f, field_label(*f)); | |
| 292 | + | } | |
| 293 | + | }); | |
| 294 | + | // Keep the operator valid for the chosen field's kind. | |
| 295 | + | let ops = ops_for(cond.field); | |
| 296 | + | if !ops.contains(&cond.op) { | |
| 297 | + | cond.op = ops[0]; | |
| 298 | + | } | |
| 299 | + | egui::ComboBox::from_id_salt(("rule_op", i)) | |
| 300 | + | .selected_text(op_label(cond.op)) | |
| 301 | + | .width(110.0) | |
| 302 | + | .show_ui(ui, |ui| { | |
| 303 | + | for op in ops { | |
| 304 | + | ui.selectable_value(&mut cond.op, *op, op_label(*op)); | |
| 305 | + | } | |
| 306 | + | }); | |
| 307 | + | if op_needs_value(cond.op) { | |
| 308 | + | ui.add( | |
| 309 | + | egui::TextEdit::singleline(&mut cond.value) | |
| 310 | + | .desired_width(70.0) | |
| 311 | + | .hint_text("value"), | |
| 312 | + | ); | |
| 313 | + | } | |
| 314 | + | if ui.add_enabled(cond_count > 1, egui::Button::new("\u{2715}").small()).clicked() { | |
| 315 | + | remove_cond = Some(i); | |
| 316 | + | } | |
| 317 | + | }); | |
| 318 | + | } | |
| 319 | + | if let Some(i) = remove_cond { | |
| 320 | + | draft.conditions.remove(i); | |
| 321 | + | draft.match_count = None; | |
| 322 | + | } | |
| 323 | + | if ui.small_button("Add condition").clicked() { | |
| 324 | + | draft.conditions.push(RuleCondition { | |
| 325 | + | field: RuleField::Name, | |
| 326 | + | op: RuleOp::Contains, | |
| 327 | + | value: String::new(), | |
| 328 | + | }); | |
| 329 | + | draft.match_count = None; | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // --- Actions --- | |
| 333 | + | ui.add_space(theme::space::SM); | |
| 334 | + | widgets::subsection_label(ui, "Then"); | |
| 335 | + | let mut remove_act: Option<usize> = None; | |
| 336 | + | for (i, action) in draft.actions.iter_mut().enumerate() { | |
| 337 | + | ui.horizontal(|ui| { | |
| 338 | + | // 0 = add tag, 1 = remove tag, 2 = stop. | |
| 339 | + | let mut kind = match action { | |
| 340 | + | RuleAction::AddTag(_) => 0u8, | |
| 341 | + | RuleAction::RemoveTag(_) => 1, | |
| 342 | + | RuleAction::Stop => 2, | |
| 343 | + | }; | |
| 344 | + | let kind_before = kind; | |
| 345 | + | egui::ComboBox::from_id_salt(("rule_action", i)) | |
| 346 | + | .selected_text(match kind { | |
| 347 | + | 0 => "Add tag", | |
| 348 | + | 1 => "Remove tag", | |
| 349 | + | _ => "Stop", | |
| 350 | + | }) | |
| 351 | + | .width(100.0) | |
| 352 | + | .show_ui(ui, |ui| { | |
| 353 | + | ui.selectable_value(&mut kind, 0, "Add tag"); | |
| 354 | + | ui.selectable_value(&mut kind, 1, "Remove tag"); | |
| 355 | + | ui.selectable_value(&mut kind, 2, "Stop"); | |
| 356 | + | }); | |
| 357 | + | if kind != kind_before { | |
| 358 | + | let tag = match action { | |
| 359 | + | RuleAction::AddTag(s) | RuleAction::RemoveTag(s) => s.clone(), | |
| 360 | + | RuleAction::Stop => String::new(), | |
| 361 | + | }; | |
| 362 | + | *action = match kind { | |
| 363 | + | 0 => RuleAction::AddTag(tag), | |
| 364 | + | 1 => RuleAction::RemoveTag(tag), | |
| 365 | + | _ => RuleAction::Stop, | |
| 366 | + | }; | |
| 367 | + | } | |
| 368 | + | if let RuleAction::AddTag(s) | RuleAction::RemoveTag(s) = action { | |
| 369 | + | ui.add( | |
| 370 | + | egui::TextEdit::singleline(s) | |
| 371 | + | .desired_width(150.0) | |
| 372 | + | .hint_text("instrument.drum.kick"), | |
| 373 | + | ); | |
| 374 | + | } | |
| 375 | + | if ui.small_button("\u{2715}").clicked() { | |
| 376 | + | remove_act = Some(i); | |
| 377 | + | } | |
| 378 | + | }); | |
| 379 | + | } | |
| 380 | + | if let Some(i) = remove_act { | |
| 381 | + | draft.actions.remove(i); | |
| 382 | + | } | |
| 383 | + | if ui.small_button("Add action").clicked() { | |
| 384 | + | draft.actions.push(RuleAction::AddTag(String::new())); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // --- Dry run + footer --- | |
| 388 | + | ui.add_space(theme::space::SM); | |
| 389 | + | ui.horizontal(|ui| { | |
| 390 | + | if ui.button("Test").on_hover_text("Count how many samples match").clicked() { | |
| 391 | + | do_test = true; | |
| 392 | + | } | |
| 393 | + | if let Some(n) = draft.match_count { | |
| 394 | + | ui.label( | |
| 395 | + | egui::RichText::new(format!("Matches {n} sample{}", if n == 1 { "" } else { "s" })) | |
| 396 | + | .small() | |
| 397 | + | .color(theme::content_secondary()), | |
| 398 | + | ); | |
| 399 | + | } | |
| 400 | + | }); | |
| 401 | + | ||
| 402 | + | ui.add_space(theme::space::SM); | |
| 403 | + | ui.horizontal(|ui| { | |
| 404 | + | if widgets::primary_button(ui, "Save").clicked() { | |
| 405 | + | do_save = true; | |
| 406 | + | } | |
| 407 | + | if ui.button("Cancel").clicked() { | |
| 408 | + | do_cancel = true; | |
| 409 | + | } | |
| 410 | + | }); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | if do_test { | |
| 414 | + | state.classifier_test_draft(); | |
| 415 | + | } | |
| 416 | + | if do_save { | |
| 417 | + | state.classifier_save_draft(); | |
| 418 | + | } | |
| 419 | + | if do_cancel { | |
| 420 | + | state.classifier_cancel_draft(); | |
| 421 | + | } | |
| 422 | + | } |
| @@ -236,6 +236,35 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 236 | 236 | } | |
| 237 | 237 | }); | |
| 238 | 238 | ||
| 239 | + | // Tag provenance: where each tag came from (manual vs rule/ml/cluster/folder), | |
| 240 | + | // so "why does this sample have this tag?" is answerable at a glance. | |
| 241 | + | if !state.selected_tags.is_empty() { | |
| 242 | + | egui::CollapsingHeader::new("Tag sources") | |
| 243 | + | .id_salt("detail_tag_sources") | |
| 244 | + | .default_open(false) | |
| 245 | + | .show(ui, |ui| { | |
| 246 | + | let tags = state.selected_tags.clone(); | |
| 247 | + | for tag in tags.iter() { | |
| 248 | + | let (label, color) = match state.selected_tag_sources.get(tag) { | |
| 249 | + | None => ("manual", theme::content_muted()), | |
| 250 | + | Some((source, _)) => match source.as_str() { | |
| 251 | + | "rule" => ("rule", theme::action()), | |
| 252 | + | "ml" => ("suggested", theme::category_five()), | |
| 253 | + | "cluster" => ("cluster", theme::category_six()), | |
| 254 | + | "harvest" => ("folder", theme::success()), | |
| 255 | + | other => (other, theme::content_muted()), | |
| 256 | + | }, | |
| 257 | + | }; | |
| 258 | + | ui.horizontal(|ui| { | |
| 259 | + | ui.label(egui::RichText::new(tag).small()); | |
| 260 | + | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 261 | + | ui.label(egui::RichText::new(label).small().color(color)); | |
| 262 | + | }); | |
| 263 | + | }); | |
| 264 | + | } | |
| 265 | + | }); | |
| 266 | + | } | |
| 267 | + | ||
| 239 | 268 | // Tag suggestions based on classification. Per-classification dismissals | |
| 240 | 269 | // let the user say "I never tag kicks with `percussion`" once and have | |
| 241 | 270 | // the suggestion stop appearing on every future kick. |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! UI submodules: each panel and widget type in its own file. | |
| 2 | 2 | ||
| 3 | + | pub mod classifier; | |
| 3 | 4 | pub mod detail; | |
| 4 | 5 | pub mod edit_panel; | |
| 5 | 6 | pub mod export_screens; |
| @@ -25,6 +25,8 @@ pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 25 | 25 | ui.add_space(theme::space::SM); | |
| 26 | 26 | draw_forge_section(ui, state); | |
| 27 | 27 | ui.add_space(theme::space::SM); | |
| 28 | + | super::classifier::draw_classifier_section(ui, state); | |
| 29 | + | ui.add_space(theme::space::SM); | |
| 28 | 30 | draw_display_section(ui, state); | |
| 29 | 31 | ui.add_space(theme::space::SM); | |
| 30 | 32 | draw_license_section(ui, state); |
| @@ -22,10 +22,11 @@ use crate::tags::validate_tag; | |||
| 22 | 22 | // ── Model ── | |
| 23 | 23 | ||
| 24 | 24 | /// How a rule's conditions combine. | |
| 25 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 25 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] | |
| 26 | 26 | #[serde(rename_all = "snake_case")] | |
| 27 | 27 | pub enum MatchMode { | |
| 28 | 28 | /// All conditions must hold (AND). | |
| 29 | + | #[default] | |
| 29 | 30 | All, | |
| 30 | 31 | /// Any condition may hold (OR). | |
| 31 | 32 | Any, |