max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
2 files changed,
+295 insertions,
-177 deletions
| @@ -4257,7 +4257,7 @@ | |||
| 4257 | 4257 | ||
| 4258 | 4258 | [[package]] | |
| 4259 | 4259 | name = "quasi-declare" | |
| 4260 | - | version = "0.1.5" | |
| 4260 | + | version = "0.1.6" | |
| 4261 | 4261 | dependencies = [ | |
| 4262 | 4262 | "proc-macro2", | |
| 4263 | 4263 | "quote", | |
| @@ -7594,10 +7594,6 @@ | |||
| 7594 | 7594 | name = "quasi-webview" | |
| 7595 | 7595 | version = "0.101.1" | |
| 7596 | 7596 | ||
| 7597 | - | [[patch.unused]] | |
| 7598 | - | name = "quasi-type" | |
| 7599 | - | version = "0.1.3" | |
| 7600 | - | ||
| 7601 | 7597 | [[patch.unused]] | |
| 7602 | 7598 | name = "kberg" | |
| 7603 | 7599 | version = "0.1.0" | |
| @@ -7609,3 +7605,7 @@ | |||
| 7609 | 7605 | [[patch.unused]] | |
| 7610 | 7606 | name = "painhours" | |
| 7611 | 7607 | version = "0.1.0" | |
| 7608 | + | ||
| 7609 | + | [[patch.unused]] | |
| 7610 | + | name = "quasi-type" | |
| 7611 | + | version = "0.1.3" |
| @@ -124,10 +124,10 @@ | |||
| 124 | 124 | //! [`Slot::removes`]: quasi_router::Slot::removes | |
| 125 | 125 | //! [`Slot::repeating`]: quasi_router::Slot::repeating | |
| 126 | 126 | ||
| 127 | - | use quasi_router::layout::{FieldKind, Readiness as Ready, Tone}; | |
| 127 | + | use quasi_declare::declare; | |
| 128 | + | use quasi_router::layout::Tone; | |
| 128 | 129 | use quasi_router::{ | |
| 129 | - | Accepted, Act, Action, Choice, Field, Locating, Node, RegionKind, Repeating, Request, Response, | |
| 130 | - | RouteError, Router, Row, Screen, Slot, Sought, Tag, | |
| 130 | + | Accepted, Action, Choice, Locating, Request, Response, RouteError, Router, Sought, Tag, | |
| 131 | 131 | }; | |
| 132 | 132 | ||
| 133 | 133 | use super::{Panels, Part, Shareable}; | |
| @@ -212,7 +212,7 @@ | |||
| 212 | 212 | ||
| 213 | 213 | /// `GET /classifier` | |
| 214 | 214 | fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 215 | - | Ok(screen(state).into()) | |
| 215 | + | Ok(showing(state)) | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | 218 | /// `POST /classifier/rules/new` | |
| @@ -639,7 +639,7 @@ | |||
| 639 | 639 | ||
| 640 | 640 | /// The screen again, which is what every act here answers with. | |
| 641 | 641 | fn settled(state: &Panels<'_>) -> Result<Response, RouteError> { | |
| 642 | - | Ok(screen(state).into()) | |
| 642 | + | Ok(showing(state)) | |
| 643 | 643 | } | |
| 644 | 644 | ||
| 645 | 645 | /// A captured name, refused when it is not there. | |
| @@ -680,410 +680,394 @@ | |||
| 680 | 680 | request.payload.get(name)?.parse().ok() | |
| 681 | 681 | } | |
| 682 | 682 | ||
| 683 | + | // --- The screen -------------------------------------------------------------- | |
| 683 | 684 | // --- The screen -------------------------------------------------------------- | |
| 684 | 685 | ||
| 685 | - | /// The whole window. | |
| 686 | - | fn screen(state: &Panels<'_>) -> Screen { | |
| 687 | - | let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Tag classifier")); | |
| 686 | + | /// The screen, read and then described. | |
| 687 | + | fn showing(state: &Panels<'_>) -> Response { | |
| 688 | + | Response::from(screen(&read(state))) | |
| 689 | + | } | |
| 688 | 690 | ||
| 689 | - | // What the shipped section put above everything for the same reason: the | |
| 690 | - | // footer status line was hidden behind the Settings modal, so a running job | |
| 691 | - | // and a failed one had to say so here. | |
| 692 | - | if let Some(doing) = state.classifier.busy() { | |
| 693 | - | body = body.with(Node::StandIn { | |
| 694 | - | state: Ready::Pending, | |
| 695 | - | message: doing, | |
| 696 | - | act: None, | |
| 697 | - | }); | |
| 698 | - | } else if let Some(why) = state.classifier.failed() { | |
| 699 | - | body = body.with(Node::banner(Tone::Warning, why)); | |
| 700 | - | } | |
| 701 | - | ||
| 702 | - | body = rules(body, state); | |
| 703 | - | body = autotag(body, state); | |
| 704 | - | body = clusters(body, state); | |
| 705 | - | body = folders(body, state); | |
| 706 | - | body = sharing(body, state); | |
| 707 | - | ||
| 708 | - | Screen::sidebar_content("Tag classifier").with(body) | |
| 691 | + | /// The whole window: five sections, and the rules editor that takes one over. | |
| 692 | + | struct Classifying { | |
| 693 | + | /// What is running, while something is. | |
| 694 | + | /// | |
| 695 | + | /// What the shipped section put above everything for the same reason: the | |
| 696 | + | /// footer status line was hidden behind the Settings modal, so a running job | |
| 697 | + | /// and a failed one had to say so here. | |
| 698 | + | doing: Option<String>, | |
| 699 | + | /// What went wrong, while nothing is running. | |
| 700 | + | failed: Option<String>, | |
| 701 | + | /// Whether anything is running, which deadens most of this screen. | |
| 702 | + | busy: bool, | |
| 703 | + | rules: Rules, | |
| 704 | + | autotag: Autotag, | |
| 705 | + | clusters: Clusters, | |
| 706 | + | folders: Folders, | |
| 707 | + | sharing: Shared, | |
| 709 | 708 | } | |
| 710 | 709 | ||
| 711 | 710 | /// Tag Rules: the list, or the editor when one is open. | |
| 712 | - | fn rules(body: Slot, state: &Panels<'_>) -> Slot { | |
| 713 | - | let body = body.with(Node::section("Tag Rules")).with(Node::text( | |
| 714 | - | "Deterministic rules that auto-apply tags by sample metadata and audio features. Rules never remove tags you added by hand.", | |
| 715 | - | )); | |
| 711 | + | struct Rules { | |
| 712 | + | /// The rules as they stand, while none is being authored. | |
| 713 | + | listing: Option<RuleList>, | |
| 714 | + | /// The rule being authored, in place of the list. | |
| 715 | + | editor: Option<Editor>, | |
| 716 | + | } | |
| 716 | 717 | ||
| 717 | - | match state.classifier.authoring() { | |
| 718 | - | Some(draft) => editor(body, state, &draft), | |
| 719 | - | None => rule_list(body, state), | |
| 718 | + | /// The rules as they stand. | |
| 719 | + | struct RuleList { | |
| 720 | + | /// What the last apply changed, where there has been one. | |
| 721 | + | last: Option<String>, | |
| 722 | + | /// Whether there are no rules at all. | |
| 723 | + | bare: bool, | |
| 724 | + | each: Vec<Listed>, | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | /// One rule in the list. | |
| 728 | + | struct Listed { | |
| 729 | + | id: String, | |
| 730 | + | /// What it is called, or that it is not. | |
| 731 | + | name: String, | |
| 732 | + | /// How much it tests and how much it does. | |
| 733 | + | meta: String, | |
| 734 | + | enabled: bool, | |
| 735 | + | /// Whether it is already the first, and whether it is already the last. | |
| 736 | + | first: bool, | |
| 737 | + | last: bool, | |
| 738 | + | /// What deleting it takes with it, and what it does not. | |
| 739 | + | confirm: String, | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | /// The rule being authored. | |
| 743 | + | struct Editor { | |
| 744 | + | name: String, | |
| 745 | + | /// Why the name was refused, while it was. | |
| 746 | + | /// | |
| 747 | + | /// Gated on a refusal rather than on emptiness: a freshly opened editor | |
| 748 | + | /// complaining about a name nobody has typed is scolding an empty form. | |
| 749 | + | problem: Option<&'static str>, | |
| 750 | + | enabled: &'static str, | |
| 751 | + | /// Whether every condition must hold, or any one of them. | |
| 752 | + | matching: &'static str, | |
| 753 | + | conditions: Vec<Condition>, | |
| 754 | + | actions: Vec<Doing>, | |
| 755 | + | /// What the last test counted, where there has been one. | |
| 756 | + | matched: Option<String>, | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | /// One condition, as three questions grouped. | |
| 760 | + | struct Condition { | |
| 761 | + | /// Its position, which is its region's name and its address. | |
| 762 | + | at: usize, | |
| 763 | + | /// Every field a rule may test. | |
| 764 | + | fields: Vec<Choice>, | |
| 765 | + | field: String, | |
| 766 | + | /// Only the comparisons this field allows, which is the whole reason the | |
| 767 | + | /// app hands over the vocabulary rather than the screen holding it. | |
| 768 | + | ops: Vec<Choice>, | |
| 769 | + | op: String, | |
| 770 | + | /// The operand, only where the comparison wants one. | |
| 771 | + | /// | |
| 772 | + | /// `op_needs_value` said in the description instead of enforced while | |
| 773 | + | /// drawing. | |
| 774 | + | value: Option<String>, | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | /// One action, as two questions grouped. | |
| 778 | + | struct Doing { | |
| 779 | + | /// Its position, which is its region's name and its address. | |
| 780 | + | at: usize, | |
| 781 | + | kind: String, | |
| 782 | + | /// The tag, except where the action is Stop. | |
| 783 | + | tag: Option<String>, | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | /// Auto-Tagging: the pass, the review door, the head, and the thresholds. | |
| 787 | + | struct Autotag { | |
| 788 | + | /// What the last run applied, where there has been one. | |
| 789 | + | last: Option<String>, | |
| 790 | + | /// What the review control reads while a queue is already waiting. | |
| 791 | + | /// | |
| 792 | + | /// Re-entry without a rescan: the queue outlives the screen, and a pass over | |
| 793 | + | /// a large library costs seconds. | |
| 794 | + | reopen: Option<String>, | |
| 795 | + | /// What the last review said, where there has been one. | |
| 796 | + | reviewed: Option<String>, | |
| 797 | + | head: Head, | |
| 798 | + | thresholds: Thresholds, | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | /// The optional trained head. | |
| 802 | + | struct Head { | |
| 803 | + | /// What it holds, or what training one would be worth. | |
| 804 | + | /// | |
| 805 | + | /// The size hint is only worth showing while there is no model, which is | |
| 806 | + | /// why one field answers both. | |
| 807 | + | said: Option<String>, | |
| 808 | + | /// What the training control reads, which is where it says whether there is | |
| 809 | + | /// already a model. | |
| 810 | + | train: &'static str, | |
| 811 | + | /// Whether there is a model to clear. | |
| 812 | + | trained: bool, | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | /// The per-tag thresholds. | |
| 816 | + | struct Thresholds { | |
| 817 | + | each: Vec<Threshold>, | |
| 818 | + | /// The tag being named for a threshold it does not have yet. | |
| 819 | + | typing: String, | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | /// One tag's two thresholds. | |
| 823 | + | struct Threshold { | |
| 824 | + | tag: String, | |
| 825 | + | review: String, | |
| 826 | + | auto: String, | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | /// Clustering: the cold-start grouping. | |
| 830 | + | struct Clusters { | |
| 831 | + | /// How many groups to ask for. | |
| 832 | + | k: String, | |
| 833 | + | piles: Vec<Pile>, | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | /// One group of similar samples. | |
| 837 | + | struct Pile { | |
| 838 | + | /// Its position, which is its region's name and its address. | |
| 839 | + | at: usize, | |
| 840 | + | /// How many samples are in it. | |
| 841 | + | said: String, | |
| 842 | + | name: String, | |
| 843 | + | /// Whether its representative is still there. | |
| 844 | + | /// | |
| 845 | + | /// The representative can be gone: deleting a sample must not take the pile | |
| 846 | + | /// or its name with it. | |
| 847 | + | playable: bool, | |
| 848 | + | /// Whether it has been named, which is what wakes Tag. | |
| 849 | + | named: bool, | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | /// Folder Tags: turn directories that hold samples into tags. | |
| 853 | + | struct Folders { | |
| 854 | + | /// Whether a scan happened and found nothing. | |
| 855 | + | /// | |
| 856 | + | /// Never scanned is not the same as a scan that found nothing, and the | |
| 857 | + | /// shipped section drew neither in that state, so this is false in both the | |
| 858 | + | /// unscanned case and the case with folders to show. | |
| 859 | + | bare: bool, | |
| 860 | + | each: Vec<Harvested>, | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | /// One folder that directly holds samples. | |
| 864 | + | struct Harvested { | |
| 865 | + | /// Its position, which is its region's name and its address. | |
| 866 | + | at: usize, | |
| 867 | + | /// What it is called, and how much is in it. | |
| 868 | + | said: String, | |
| 869 | + | tag: String, | |
| 870 | + | /// Whether a tag has been typed, which is what wakes Apply. | |
| 871 | + | named: bool, | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | /// Shared Classifiers: export, import, and the layers an import leaves. | |
| 875 | + | struct Shared { | |
| 876 | + | /// What the export file will be called. | |
| 877 | + | name: String, | |
| 878 | + | /// The three parts, each ticked or not. | |
| 879 | + | parts: Vec<Included>, | |
| 880 | + | /// Whether there is anything worth writing. | |
| 881 | + | worth: bool, | |
| 882 | + | /// What the last export said, where there has been one. | |
| 883 | + | exported: Option<String>, | |
| 884 | + | /// What the last import said, where there has been one. | |
| 885 | + | imported: Option<String>, | |
| 886 | + | layers: Vec<Layer>, | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | /// One kind of thing an export may carry. | |
| 890 | + | struct Included { | |
| 891 | + | name: &'static str, | |
| 892 | + | label: &'static str, | |
| 893 | + | /// On or off, as the control carries it. | |
| 894 | + | value: &'static str, | |
| 895 | + | /// Said rather than silently contributing nothing: a part that is ticked | |
| 896 | + | /// and does not exist is why `worth_writing` asks about both. | |
| 897 | + | hint: Option<&'static str>, | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | /// One imported layer. | |
| 901 | + | struct Layer { | |
| 902 | + | id: String, | |
| 903 | + | name: String, | |
| 904 | + | /// What it brought with it. | |
| 905 | + | said: String, | |
| 906 | + | enabled: &'static str, | |
| 907 | + | /// How much it counts next to your own tagging. | |
| 908 | + | weight: String, | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | /// What the screen draws, read off the app. | |
| 912 | + | fn read(state: &Panels<'_>) -> Classifying { | |
| 913 | + | let doing = state.classifier.busy(); | |
| 914 | + | let busy = doing.is_some(); | |
| 915 | + | Classifying { | |
| 916 | + | // A failure is only worth reporting while nothing is running, which is | |
| 917 | + | // what the shipped section's `else if` said. | |
| 918 | + | failed: (!busy).then(|| state.classifier.failed()).flatten(), | |
| 919 | + | doing, | |
| 920 | + | busy, | |
| 921 | + | rules: rules_read(state), | |
| 922 | + | autotag: autotag_read(state), | |
| 923 | + | clusters: clusters_read(state), | |
| 924 | + | folders: folders_read(state), | |
| 925 | + | sharing: sharing_read(state), | |
| 926 | + | } | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | /// The rules, or the one being authored in place of them. | |
| 930 | + | fn rules_read(state: &Panels<'_>) -> Rules { | |
| 931 | + | let authoring = state.classifier.authoring(); | |
| 932 | + | Rules { | |
| 933 | + | listing: authoring.is_none().then(|| rule_list_read(state)), | |
| 934 | + | editor: authoring.map(|draft| editor_read(state, &draft)), | |
| 720 | 935 | } | |
| 721 | 936 | } | |
| 722 | 937 | ||
| 723 | 938 | /// The rules as they stand. | |
| 724 | - | fn rule_list(body: Slot, state: &Panels<'_>) -> Slot { | |
| 725 | - | let busy = state.classifier.busy().is_some(); | |
| 726 | - | let mut body = body | |
| 727 | - | .with(Node::Act(unless( | |
| 728 | - | Act::new("New rule", Action::post("/classifier/rules/new")), | |
| 729 | - | busy, | |
| 730 | - | ))) | |
| 731 | - | .with(Node::text( | |
| 732 | - | "Re-evaluate every rule across the whole library.", | |
| 733 | - | )) | |
| 734 | - | .with(Node::Act(unless( | |
| 735 | - | Act::new("Apply rules now", Action::post("/classifier/rules/apply")), | |
| 736 | - | busy, | |
| 737 | - | ))) | |
| 738 | - | .with(Node::text( | |
| 739 | - | "Starter rules cover the common instrument and format words (Kick.wav becomes instrument.drum.kick). They arrive disabled: review them, then enable the ones you want.", | |
| 740 | - | )) | |
| 741 | - | .with(Node::Act(unless( | |
| 742 | - | Act::new("Add starter rules", Action::post("/classifier/rules/starter")), | |
| 743 | - | busy, | |
| 744 | - | ))); | |
| 745 | - | ||
| 746 | - | if let Some(changed) = state.classifier.last_apply() { | |
| 747 | - | body = body.with(Node::text(format!( | |
| 748 | - | "Last apply: {changed} sample{} updated", | |
| 749 | - | plural(changed), | |
| 750 | - | ))); | |
| 751 | - | } | |
| 752 | - | ||
| 939 | + | fn rule_list_read(state: &Panels<'_>) -> RuleList { | |
| 753 | 940 | let all = state.classifier.rules(); | |
| 754 | - | if all.is_empty() { | |
| 755 | - | return body.with(Node::empty( | |
| 756 | - | "No rules yet. New rules start empty; you decide what gets tagged.", | |
| 757 | - | )); | |
| 941 | + | RuleList { | |
| 942 | + | last: state | |
| 943 | + | .classifier | |
| 944 | + | .last_apply() | |
| 945 | + | .map(|changed| format!("Last apply: {changed} sample{} updated", plural(changed))), | |
| 946 | + | bare: all.is_empty(), | |
| 947 | + | each: all | |
| 948 | + | .iter() | |
| 949 | + | .map(|rule| { | |
| 950 | + | let named = if rule.name.trim().is_empty() { | |
| 951 | + | "(unnamed)" | |
| 952 | + | } else { | |
| 953 | + | rule.name.trim() | |
| 954 | + | }; | |
| 955 | + | Listed { | |
| 956 | + | id: rule.id.clone(), | |
| 957 | + | name: named.to_owned(), | |
| 958 | + | meta: format!("{} cond \u{2192} {} act", rule.conditions, rule.actions), | |
| 959 | + | enabled: rule.enabled, | |
| 960 | + | first: rule.first, | |
| 961 | + | last: rule.last, | |
| 962 | + | confirm: format!( | |
| 963 | + | "Delete the rule \"{named}\"? Tags it already applied stay where they are." | |
| 964 | + | ), | |
| 965 | + | } | |
| 966 | + | }) | |
| 967 | + | .collect(), | |
| 758 | 968 | } | |
| 759 | - | ||
| 760 | - | let rows = all | |
| 761 | - | .iter() | |
| 762 | - | .map(|rule| { | |
| 763 | - | let named = !rule.name.trim().is_empty(); | |
| 764 | - | let mut row = Row::new(if named { &rule.name } else { "(unnamed)" }) | |
| 765 | - | .meta(format!( | |
| 766 | - | "{} cond \u{2192} {} act", | |
| 767 | - | rule.conditions, rule.actions | |
| 768 | - | )) | |
| 769 | - | .toggling( | |
| 770 | - | rule.enabled, | |
| 771 | - | Action::post(format!("/classifier/rules/{}/enabled", rule.id)), | |
| 772 | - | ); | |
| 773 | - | if !rule.enabled { | |
| 774 | - | row = row.token(Tag::badge("off")); | |
| 775 | - | } | |
| 776 | - | row.act(Act::new( | |
| 777 | - | "Edit", | |
| 778 | - | Action::post(format!("/classifier/rules/{}/edit", rule.id)), | |
| 779 | - | )) | |
| 780 | - | .act(unless( | |
| 781 | - | Act::new( | |
| 782 | - | "Earlier", | |
| 783 | - | Action::post(format!("/classifier/rules/{}/up", rule.id)), | |
| 784 | - | ), | |
| 785 | - | rule.first, | |
| 786 | - | )) | |
| 787 | - | .act(unless( | |
| 788 | - | Act::new( | |
| 789 | - | "Later", | |
| 790 | - | Action::post(format!("/classifier/rules/{}/down", rule.id)), | |
| 791 | - | ), | |
| 792 | - | rule.last, | |
| 793 | - | )) | |
| 794 | - | .act( | |
| 795 | - | Act::new( | |
| 796 | - | "Delete", | |
| 797 | - | Action::post(format!("/classifier/rules/{}/delete", rule.id)), | |
| 798 | - | ) | |
| 799 | - | .tone(Tone::Danger) | |
| 800 | - | .confirm(format!( | |
| 801 | - | "Delete the rule \"{}\"? Tags it already applied stay where they are.", | |
| 802 | - | if named { &rule.name } else { "(unnamed)" }, | |
| 803 | - | )), | |
| 804 | - | ) | |
| 805 | - | }) | |
| 806 | - | .collect(); | |
| 807 | - | ||
| 808 | - | body.with(Node::List { rows, more: None }) | |
| 809 | 969 | } | |
| 810 | 970 | ||
| 811 | - | /// The rule being authored, in place of the list. | |
| 812 | - | fn editor(body: Slot, state: &Panels<'_>, draft: &super::Authoring) -> Slot { | |
| 813 | - | let mut name = Field::new(FieldKind::Text, "name", "Name") | |
| 814 | - | .value(draft.name.clone()) | |
| 815 | - | .writes(Action::post("/classifier/draft/name")); | |
| 816 | - | name.required = true; | |
| 817 | - | name.placeholder = Some("Kick drums".to_owned()); | |
| 818 | - | // Gated on a refusal rather than on emptiness: a freshly opened editor | |
| 819 | - | // complaining about a name nobody has typed is scolding an empty form. | |
| 820 | - | if draft.refused && draft.name.trim().is_empty() { | |
| 821 | - | name = name.error("Name the rule before saving."); | |
| 822 | - | } | |
| 823 | - | ||
| 824 | - | let mut body = body | |
| 825 | - | .with(Node::Field(Box::new(name))) | |
| 826 | - | .with(Node::Field(Box::new( | |
| 827 | - | Field::new(FieldKind::Checkbox, "enabled", "Enabled") | |
| 828 | - | .value(if draft.enabled { "on" } else { "" }) | |
| 829 | - | .writes(Action::post("/classifier/draft/enabled")), | |
| 830 | - | ))) | |
| 831 | - | .with(Node::Field(Box::new( | |
| 832 | - | Field::radio( | |
| 833 | - | "match", | |
| 834 | - | "Match", | |
| 835 | - | vec![ | |
| 836 | - | Choice::new("all", "All of these conditions"), | |
| 837 | - | Choice::new("any", "Any of these conditions"), | |
| 838 | - | ], | |
| 839 | - | ) | |
| 840 | - | .value(if draft.all { "all" } else { "any" }) | |
| 841 | - | .writes(Action::post("/classifier/draft/match")), | |
| 842 | - | ))) | |
| 843 | - | .with(Node::section("When")); | |
| 844 | - | ||
| 845 | - | // The conditions and the actions, each as slots of one repeating question. | |
| 846 | - | // quasicoherent `f7abbc08`: the regions were always here and nothing said | |
| 847 | - | // they were slots, so no renderer could number them and "at least one | |
| 848 | - | // condition" was this file disabling its own last Remove. | |
| 849 | - | // | |
| 850 | - | // `least(1)` is that rule, said once. `least(0)` on the actions is the | |
| 851 | - | // honest answer for them: a rule with no actions is describable and the | |
| 852 | - | // editor has never stopped anyone writing one. | |
| 971 | + | /// The rule being authored, read off the draft. | |
| 972 | + | fn editor_read(state: &Panels<'_>, draft: &super::Authoring) -> Editor { | |
| 853 | 973 | let testable = state.classifier.testable(); | |
| 854 | - | let mut conditions = Slot::new("conditions", RegionKind::Group).repeating( | |
| 855 | - | Repeating::new( | |
| 856 | - | "Condition", | |
| 857 | - | Act::new( | |
| 858 | - | "Add condition", | |
| 859 | - | Action::post("/classifier/draft/conditions/add"), | |
| 860 | - | ), | |
| 861 | - | ) | |
| 862 | - | .least(1), | |
| 863 | - | ); | |
| 864 | - | for (at, condition) in draft.conditions.iter().enumerate() { | |
| 865 | - | conditions = conditions.with(condition_region(at, condition, &testable)); | |
| 974 | + | Editor { | |
| 975 | + | name: draft.name.clone(), | |
| 976 | + | problem: (draft.refused && draft.name.trim().is_empty()) | |
| 977 | + | .then_some("Name the rule before saving."), | |
| 978 | + | enabled: switched(draft.enabled), | |
| 979 | + | matching: if draft.all { "all" } else { "any" }, | |
| 980 | + | conditions: draft | |
| 981 | + | .conditions | |
| 982 | + | .iter() | |
| 983 | + | .enumerate() |
Lines truncated