Skip to main content

max / audiofiles

Describe the import flow and the sweep Nine stages as nine members of one read, which is export's shape. Sixteen declarations, and three things that were not screens went into the read instead: `rail` is a fact about the flow rather than about each stage, so the sentence sits on the flow and the region says it once for the seven screens that have one; `batch` answers `Vec<Figure>` and `strategy_field` answers `Vec<Choice>`, both data for a member the declaration writes. `failed` took the body region and handed it back. It is a `-> Vec<Node>` the two progress screens splice with `extend`, which is the container rule applied for the eleventh time. The preflight and the sweep were building screens by hand in their handlers; both are declarations now, and `stop_sweep` and `sweeping` share the one that answers whether anything is being cleaned up. `decision_route` is an address said where it is used. The `read` route handler is `open_sample`, so the read can be called `read`. No production earned.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 23:51 UTC
Signed with PGP, not checked
Commit: 2e1e25c25994e773ae673c4f372d6f236c94833b
Parent: 52fcfec
1 file changed, +189 insertions, -129 deletions
@@ -153,15 +153,16 @@
153 153 //! decide whether there is anything to confirm, and that is the app deciding
154 154 //! when to ask rather than a fact about the question.
155 155
156 - use quasi_router::layout::{Family, FieldKind, Readiness, Tone};
156 + use quasi_declare::declare;
157 + use quasi_router::layout::{Family, Tone};
157 158 use quasi_router::{
158 - Accepted, Act, Action, Choice, Consult, Field, Figure, Locating, Meter, Node, Outcome, Prose,
159 - RegionKind, Request, Response, RouteError, Router, Row, Screen, Slot, Sought,
159 + Accepted, Act, Action, Choice, Consult, Figure, Locating, Node, Outcome, RegionKind, Request,
160 + Response, RouteError, Router, Screen, Slot, Sought,
160 161 };
161 162
162 163 use super::{
163 - Decision, Failure, FolderTags, Halted, Measure, Measures, Order, Panels, Reviewed, Stage,
164 - Strategy, Sweep, VaultChoice,
164 + Decision, Failure, FolderTags, Halted, Measure, Order, Panels, Reviewed, Stage, Strategy,
165 + Sweep, VaultChoice,
165 166 };
166 167
167 168 /// The region the preflight answers into.
@@ -270,7 +271,7 @@
270 271 .post("/import/review/all/reject", reject_all)
271 272 .post("/import/review/apply", apply_suggestions)
272 273 .post("/import/review/discard", discard_suggestions)
273 - .post("/import/review/{at}/read", read)
274 + .post("/import/review/{at}/read", open_sample)
274 275 .post("/import/review/{at}/judge", judge);
275 276
276 277 let router = router
@@ -293,39 +294,13 @@
293 294 .waiting()
294 295 .ok_or_else(|| RouteError::not_found("no import is waiting"))?;
295 296
296 - let body = Slot::new(BODY, RegionKind::Pane)
297 - .with(Node::page("Import folder"))
298 - .with(Node::text(format!(
299 - "About to import {} audio file{} (~{}) from {}",
300 - waiting.files,
301 - if waiting.files == 1 { "" } else { "s" },
302 - waiting.size,
303 - waiting.source,
304 - )))
305 - // The reassurance the shipped modal draws as a small muted line. It is a
306 - // fact about the whole operation rather than about either answer, so it
307 - // is prose and not a field's hint.
308 - .with(Node::text(
309 - "Files stay where they are: audiofiles only indexes them.",
310 - ))
311 - .with(Node::Form {
312 - // The checkbox the shipped modal keeps on `BrowserState`. See the
313 - // module header: here it is submitted with the answer it qualifies.
314 - fields: vec![Field::new(
315 - FieldKind::Checkbox,
316 - AGAIN,
317 - "Don't ask again for folders this size",
318 - )],
319 - submit: "Import".to_owned(),
320 - action: Action::post("/import/preflight"),
321 - })
322 - .with(Node::Act(
323 - Act::new("Cancel", Action::post("/import/preflight/cancel")).key("esc"),
324 - ));
325 -
326 - Ok(Response::from(Outcome::Over(
327 - Screen::sidebar_content("Import folder").with(body),
328 - )))
297 + Ok(Response::from(Outcome::Over(preflight(&format!(
298 + "About to import {} audio file{} (~{}) from {}",
299 + waiting.files,
300 + plural(waiting.files),
301 + waiting.size,
302 + waiting.source,
303 + )))))
329 304 }
330 305
331 306 /// `POST /import/preflight`
@@ -350,7 +325,7 @@
350 325
351 326 /// `GET /import`
352 327 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
353 - Ok(flow(state).into())
328 + Ok(showing(state))
354 329 }
355 330
356 331 /// `GET /import/open`
@@ -545,7 +520,7 @@
545 520 return Err(RouteError::not_found("no such import strategy"));
546 521 }
547 522 state.importing.decide(decision, value);
548 - Ok(flow(state).into())
523 + Ok(showing(state))
549 524 }
550 525
551 526 /// `POST /import/start`
@@ -569,25 +544,25 @@
569 544 return Err(RouteError::not_found(reason));
570 545 }
571 546 state.importing.begin();
572 - Ok(flow(state).into())
547 + Ok(showing(state))
573 548 }
574 549
575 550 /// `POST /import/stop`
576 551 fn stop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
577 552 state.importing.stop();
578 - Ok(flow(state).into())
553 + Ok(showing(state))
579 554 }
580 555
581 556 /// `POST /import/retry`
582 557 fn retry(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
583 558 state.importing.retry();
584 - Ok(flow(state).into())
559 + Ok(showing(state))
585 560 }
586 561
587 562 /// `POST /import/dismiss`
588 563 fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
589 564 state.importing.dismiss();
590 - Ok(flow(state).into())
565 + Ok(showing(state))
591 566 }
592 567
593 568 /// `POST /import/folders/{at}/tags`
@@ -595,7 +570,7 @@
595 570 let at = folder_at(state, &request)?;
596 571 let typed = request.payload.get(TAGS).unwrap_or_default();
597 572 state.importing.tag_folder(at, typed);
598 - Ok(flow(state).into())
573 + Ok(showing(state))
599 574 }
600 575
601 576 /// `POST /import/folders/all`
@@ -612,7 +587,7 @@
612 587 return Err(RouteError::not_found("type at least one tag to apply"));
613 588 }
614 589 state.importing.tag_every_folder(typed);
615 - Ok(flow(state).into())
590 + Ok(showing(state))
616 591 }
617 592
618 593 /// `POST /import/folders/apply`
@@ -628,13 +603,13 @@
628 603 return Err(RouteError::not_found("nothing has been typed to apply"));
629 604 }
630 605 state.importing.apply_folder_tags();
631 - Ok(flow(state).into())
606 + Ok(showing(state))
632 607 }
633 608
634 609 /// `POST /import/folders/skip`
635 610 fn skip_folder_tags(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
636 611 state.importing.skip_folder_tags();
637 - Ok(flow(state).into())
612 + Ok(showing(state))
638 613 }
639 614
640 615 /// `POST /import/measure/{measure}`
@@ -647,13 +622,13 @@
647 622 // would then have to agree with three renderers about.
648 623 let wanted = !request.payload.get(name).unwrap_or_default().is_empty();
649 624 state.importing.measure(measure, wanted);
650 - Ok(flow(state).into())
625 + Ok(showing(state))
651 626 }
652 627
653 628 /// `POST /import/analyse`
654 629 fn analyse(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
655 630 state.importing.analyse();
656 - Ok(flow(state).into())
631 + Ok(showing(state))
657 632 }
658 633
659 634 /// `POST /import/analyse/back`
@@ -671,25 +646,25 @@
671 646 ));
672 647 }
673 648 state.importing.back_to_tagging();
674 - Ok(flow(state).into())
649 + Ok(showing(state))
675 650 }
676 651
677 652 /// `POST /import/analyse/skip`
678 653 fn skip_analysis(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
679 654 state.importing.skip_analysis();
680 - Ok(flow(state).into())
655 + Ok(showing(state))
681 656 }
682 657
683 658 /// `POST /import/analyse/stop`
684 659 fn stop_analysis(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
685 660 state.importing.stop_analysis();
686 - Ok(flow(state).into())
661 + Ok(showing(state))
687 662 }
688 663
689 664 /// `POST /import/analyse/retry`
690 665 fn retry_analysis(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
691 666 state.importing.retry_analysis();
692 - Ok(flow(state).into())
667 + Ok(showing(state))
693 668 }
694 669
695 670 /// `POST /import/review/order`
@@ -701,14 +676,14 @@
701 676 .unwrap_or_default();
702 677 let order = Order::from_key(chosen).ok_or_else(|| RouteError::not_found("no such order"))?;
703 678 state.importing.order(order);
704 - Ok(flow(state).into())
679 + Ok(showing(state))
705 680 }
706 681
707 682 /// `POST /import/review/{at}/read`
708 - fn read(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
683 + fn open_sample(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
709 684 let at = reviewed_at(state, &request)?;
710 685 state.importing.read(at);
711 - Ok(flow(state).into())
686 + Ok(showing(state))
712 687 }
713 688
714 689 /// `POST /import/review/{at}/judge`
@@ -736,19 +711,19 @@
736 711 .and_then(|item| item.suggestions.iter().find(|held| held.tag == tag))
737 712 .ok_or_else(|| RouteError::not_found("no such suggestion"))?;
738 713 state.importing.judge(at, tag, !held.accepted);
739 - Ok(flow(state).into())
714 + Ok(showing(state))
740 715 }
741 716
742 717 /// `POST /import/review/all/accept`
743 718 fn accept_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
744 719 state.importing.judge_all(true);
745 - Ok(flow(state).into())
720 + Ok(showing(state))
746 721 }
747 722
748 723 /// `POST /import/review/all/reject`
749 724 fn reject_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
750 725 state.importing.judge_all(false);
751 - Ok(flow(state).into())
726 + Ok(showing(state))
752 727 }
753 728
754 729 /// `POST /import/review/apply`
@@ -764,25 +739,25 @@
764 739 return Err(RouteError::not_found("no suggestions have been accepted"));
765 740 }
766 741 state.importing.apply_suggestions();
767 - Ok(flow(state).into())
742 + Ok(showing(state))
768 743 }
769 744
770 745 /// `POST /import/review/discard`
771 746 fn discard_suggestions(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
772 747 state.importing.discard_suggestions();
773 - Ok(flow(state).into())
748 + Ok(showing(state))
774 749 }
775 750
776 751 /// `POST /import/summary/keep`
777 752 fn keep_failed(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
778 753 state.importing.keep_failed();
779 - Ok(flow(state).into())
754 + Ok(showing(state))
780 755 }
781 756
782 757 /// `POST /import/summary/purge`
783 758 fn purge_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
784 759 state.importing.purge_failed(None);
785 - Ok(flow(state).into())
760 + Ok(showing(state))
786 761 }
787 762
788 763 /// `POST /import/summary/{at}/purge`
@@ -799,7 +774,7 @@
799 774 return Err(RouteError::not_found("no such file"));
800 775 }
801 776 state.importing.purge_failed(Some(at));
802 - Ok(flow(state).into())
777 + Ok(showing(state))
803 778 }
804 779
805 780 /// `GET /cleanup`
@@ -812,171 +787,517 @@
812 787 .importing
813 788 .sweeping()
814 789 .ok_or_else(|| RouteError::not_found("nothing is being cleaned up"))?;
815 - Ok(Screen::sidebar_content("Cleaning up")
816 - .with(sweep_body(&sweep))
817 - .into())
790 + Ok(cleanup(&cleaning(Some(&sweep))).into())
818 791 }
819 792
820 793 /// `POST /cleanup/stop`
821 794 fn stop_sweep(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
822 795 state.importing.stop_sweep();
823 - Ok(Screen::sidebar_content("Cleaning up")
824 - .with(match state.importing.sweeping() {
825 - Some(sweep) => sweep_body(&sweep),
826 - None => {
827 - Slot::new(SWEEP, RegionKind::Pane).with(Node::empty("Nothing is being cleaned up."))
828 - }
829 - })
830 - .into())
796 + Ok(cleanup(&cleaning(state.importing.sweeping().as_ref())).into())
831 797 }
832 798
833 799 // --- the screens ---
834 800
835 - /// Whichever of the nine screens the flow is on.
836 - fn flow(state: &Panels<'_>) -> Screen {
837 - let body = match state.importing.stage() {
838 - Stage::Idle => idle(),
839 - Stage::Configuring {
840 - source,
841 - files,
842 - strategy,
843 - vault_name,
844 - vaults,
845 - merging_into,
846 - } => configuring(&source, files, strategy, &vault_name, &vaults, merging_into),
847 - Stage::Scanning { found, size } => scanning(found, size.as_deref()),
848 - Stage::Copying {
849 - done,
850 - total,
851 - current,
852 - size,
853 - in_place,
854 - failures,
855 - } => copying(done, total, &current, size.as_deref(), in_place, &failures),
856 - Stage::Tagging { folders } => tagging(&folders),
857 - Stage::Choosing {
858 - samples,
859 - measures,
860 - resumable,
861 - } => choosing(samples, measures, resumable),
862 - Stage::Analysing {
863 - done,
864 - total,
865 - current,
866 - failures,
867 - } => analysing(done, total, &current, &failures),
868 - Stage::Reviewing { items, at, order } => reviewing(&items, at, order),
869 - Stage::Summary {
870 - rejected,
871 - unanalysed,
872 - } => summary(&rejected, &unanalysed),
873 - Stage::Stopped { what, done, total } => stopped(what, done, total),
874 - };
875 - Screen::sidebar_content("Import").with(body)
801 + /// The flow, read and then described.
802 + fn showing(state: &Panels<'_>) -> Response {
803 + Response::from(flow(&read(state)))
876 804 }
877 805
878 - /// Nothing is being imported.
806 + /// Whichever of the nine screens the flow is on.
879 807 ///
880 - /// A way in rather than a bare sentence, which is where this screen differs from
881 - /// [`export`](super::export)'s idle: an export is entered by selecting samples in
882 - /// a different screen and there is nothing honest to offer here, while an import
883 - /// is entered by choosing a folder and that is a control.
884 - fn idle() -> Slot {
885 - Slot::new(FLOW, RegionKind::Pane).with(
886 - Node::empty("Nothing is being imported.")
887 - .offering(Act::new("Import...", Action::get("/import/open"))),
888 - )
808 + /// Nine members rather than nine returns, which is [`export`](super::export)'s
809 + /// shape: the region is one region, the flow is at exactly one stage, and the
810 + /// description says which by looping over the member that is there.
811 + struct Flow {
812 + /// Where the flow is, as prose.
813 + ///
814 + /// The degraded stage rail, and the reason it sits here rather than on each
815 + /// stage: which of four steps is up is a fact about the flow, and seven of
816 + /// the nine screens say it first. See the module header,
817 + /// `quasi:vocabulary:unchosen-stage`.
818 + rail: Option<String>,
819 + /// Whether nothing is being imported.
820 + idle: bool,
821 + configuring: Option<Configuring>,
822 + scanning: Option<Scanning>,
823 + copying: Option<Copying>,
824 + tagging: Option<Tagging>,
825 + choosing: Option<Choosing>,
826 + analysing: Option<Analysing>,
827 + reviewing: Option<Reviewing>,
828 + summary: Option<Summary>,
829 + stopped: Option<Stopped>,
889 830 }
890 831
891 832 /// Where the files are going, before any of them go.
892 - fn configuring(
833 + struct Configuring {
834 + /// The folder they are coming from.
835 + source: String,
836 + /// How many audio files the dry-run scan found in it.
837 + found: String,
838 + /// What counts as one, and what happens to the ones already held.
839 + supported: String,
840 + /// The three answers, the third carrying the reason it cannot be taken
841 + /// where it cannot.
842 + strategies: Vec<Choice>,
843 + /// Which of them is chosen.
844 + strategy: &'static str,
845 + /// Naming a new vault, while that is the answer.
846 + vault: Option<NewVault>,
847 + /// Picking a vault to merge into, while that is the answer and there are
848 + /// any.
849 + merge: Option<Merge>,
850 + /// Whether a merge was chosen with nothing to merge into.
851 + ///
852 + /// Reachable only by arriving here with the strategy already set, since the
853 + /// choice itself carries its own refusal. Said as a banner rather than as an
854 + /// error on a picker that has nothing to pick from.
855 + nowhere: bool,
856 + /// Whether the import may start.
857 + ready: bool,
858 + }
859 +
860 + /// A new vault, named.
861 + struct NewVault {
862 + /// What has been typed.
863 + typed: String,
864 + /// What is wrong with it, while something is.
865 + ///
866 + /// The state the shipped screen used to leave unexplained: Import disabled
867 + /// itself and said why only to a pointer that hovered it.
868 + problem: Option<&'static str>,
869 + }
870 +
871 + /// An existing vault, merged into.
872 + struct Merge {
873 + /// Every vault, by position.
874 + options: Vec<Choice>,
875 + /// Which is chosen.
876 + chosen: String,
877 + }
878 +
879 + /// Walking the folder, before there is a count.
880 + struct Scanning {
881 + /// How far the walk has got, however much of that is known.
882 + said: String,
883 + }
884 +
885 + /// Files being copied in.
886 + struct Copying {
887 + /// What the whole set weighs, and which of the two that means.
888 + weight: Option<Weight>,
889 + /// How many have landed, against how many there are.
890 + gauge: Gauge,
891 + /// The one being copied now, while one is named.
892 + current: Option<String>,
893 + /// What has gone wrong so far, while anything has.
894 + trouble: Option<Trouble>,
895 + }
896 +
897 + /// What the set weighs, and whether that is disk being spent.
898 + ///
899 + /// Which of the two this says is the whole point of the line: an import that
900 + /// references files where they sit costs no disk, and one that copies them costs
901 + /// this much.
902 + struct Weight {
903 + said: String,
904 + tone: Tone,
905 + }
906 +
907 + /// Naming what came in.
908 + struct Tagging {
909 + /// How many folders, and how many samples in them.
910 + said: String,
911 + /// Each folder, with whatever has been typed against it.
912 + folders: Vec<Folder>,
913 + /// Whether nothing has been typed anywhere, which deadens Apply.
914 + nothing_typed: bool,
915 + }
916 +
917 + /// One imported folder, and the tags typed against it.
918 + struct Folder {
919 + /// Its position, which is both its region's name and its address.
920 + at: usize,
921 + /// What it is called, and how much is in it.
922 + heading: String,
923 + /// What has been typed.
924 + typed: String,
925 + /// The tags that are not tags, while any were typed.
926 + invalid: Option<String>,
927 + }
928 +
929 + /// What the analysis will measure.
930 + struct Choosing {
931 + /// How many samples it would run over.
932 + said: String,
933 + /// Every measure, ticked or not.
934 + measures: Vec<Ticked>,
935 + /// Whether the tagging step can be returned to.
936 + resumable: bool,
937 + }
938 +
939 + /// One measure, and whether it is on.
940 + struct Ticked {
941 + name: &'static str,
942 + label: &'static str,
943 + /// On or off, as the control carries it.
944 + value: &'static str,
945 + }
946 +
947 + /// Samples being analysed.
948 + struct Analysing {
949 + /// How many are done, against how many there are.
950 + gauge: Gauge,
951 + /// The one being analysed now, while one is named.
952 + current: Option<String>,
Lines truncated