Skip to main content

max / audiofiles

Describe the import flow, and the two menus that open the flows The largest remaining pass: `ui/import_screens/{configure,tagging,progress, summary}.rs`, 1,332 lines, as nine stages of one described flow at `/import`. `export`'s rule applied rather than found -- which screen is showing is a fact about the app, so one address answers all nine and nothing navigates between them. Grows `Importing` rather than adding a fourteenth trait, which is what its own header said this pass would do. The preflight and the flow are one subject asked at two moments. Three screens in those files are not stages of the flow, and the port says so: the orphan sweep answers `/cleanup`, the export arm of the cancelled screen is `Phase::Cancelled` already, and `ReviewLibrary` is a different screen sharing an enum. The toolbar's Import and Export menus are described too. The toolbar port left them out because they "belong with the import flow, which is its own remaining pass"; Import is an overlay of three doors, Export a single act. That also fixes `/files/import`, which the empty file list offered and no route served. Two findings: - `quasi:vocabulary:unchosen-stage`. `Slot::showing_one` carries a stage rail's names and position and also says the reader may change which child is up, which a wizard's stage is not. Four consumers in this pass; degraded to prose. - `quasi:vocabulary:disabled-reason`. `Choice::unless` carries why an option cannot be taken and `Act::disabled` carries nothing. Four consumers. And four more for `quasi:vocabulary:host-save-location`, with a second half nothing had hit: a route that hands off to the host has no outcome meaning "nothing here changed". 546 tests green with --features quasi, 30 of them new.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 20:53 UTC
Signed with PGP, not checked
Commit: 04d8628db63810370188875af6e4c4ddd4916854
Parent: 9dde498
8 files changed, +1793 insertions, -28 deletions
@@ -220,6 +220,38 @@
220 220 crate::quasi::panel::draw_export(ctx, state);
221 221 }
222 222
223 + // The described import flow, beside whichever of the shipped wizard screens
224 + // is showing, and on the same terms as the export one: the shipped side
225 + // takes over the central pane rather than being a window, so the described
226 + // window opens when the flow does and closes when it ends.
227 + #[cfg(feature = "quasi")]
228 + if matches!(
229 + state.import_wf.import_mode,
230 + crate::state::ImportMode::ConfigureImport { .. }
231 + | crate::state::ImportMode::Importing { .. }
232 + | crate::state::ImportMode::TagFolders { .. }
233 + | crate::state::ImportMode::ConfigureAnalysis { .. }
234 + | crate::state::ImportMode::Analyzing { .. }
235 + | crate::state::ImportMode::ReviewSuggestions { .. }
236 + | crate::state::ImportMode::ReviewErrors
237 + | crate::state::ImportMode::OperationCancelled {
238 + kind: crate::state::CancelKind::Import | crate::state::CancelKind::Analysis,
239 + ..
240 + }
241 + ) {
242 + crate::quasi::panel::draw_import(ctx, state);
243 + }
244 +
245 + // The sweep, which shares a shipped file with the import screens and is not
246 + // one of them. See `quasi::importing`'s header.
247 + #[cfg(feature = "quasi")]
248 + if matches!(
249 + state.import_wf.import_mode,
250 + crate::state::ImportMode::Cleaning { .. }
251 + ) {
252 + crate::quasi::panel::draw_sweep(ctx, state);
253 + }
254 +
223 255 // Sync panel overlay
224 256 if state.sync.show_panel {
225 257 // The described one beside it, on the same toggle. `None` is the case
@@ -69,8 +69,8 @@
69 69
70 70 use quasi_router::layout::{FieldKind, Selector, Tone};
71 71 use quasi_router::{
72 - Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
73 - Slot,
72 + Act, Action, Choice, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router,
73 + Screen, Slot,
74 74 };
75 75
76 76 use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject};
@@ -100,6 +100,7 @@
100 100 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
101 101 router
102 102 .get("/export", index)
103 + .post("/export/begin", begin)
103 104 .post("/export/set/{setting}", configure)
104 105 .post("/export/start", start)
105 106 .post("/export/cancel", cancel)
@@ -111,6 +112,23 @@
111 112 Ok(screen(state).into())
112 113 }
113 114
115 + /// `POST /export/begin`
116 + ///
117 + /// The toolbar's Export button, which the toolbar port left out because it
118 + /// "belongs with the import flow, which is its own remaining pass". It is the
119 + /// door rather than a stage: the flow's four screens were described first and
120 + /// had no way in, so pressing Export was the one thing about exporting that the
121 + /// description could not say.
122 + ///
123 + /// It answers the flow rather than the screen it was pressed on, because the
124 + /// flow takes over the pane in the shipped app too. The answer is built before
125 + /// the intent lands, so the first frame reads `Idle` and the reload corrects it
126 + /// — the standing cost this module's header is about.
127 + fn begin(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
128 + state.export.open();
129 + Ok(Response::from(Outcome::Goto(Action::get("/export"))))
130 + }
131 +
114 132 /// `POST /export/set/{setting}`
115 133 ///
116 134 /// One route for every control on the configure screen, which is `settings.rs`'s
@@ -144,7 +144,7 @@
144 144 // still has content, so emptiness belongs to the thing that is empty.
145 145 Slot::new(BODY, RegionKind::Pane).with(
146 146 Node::empty("Nothing here yet.")
147 - .offering(Act::new("Import samples", Action::post("/files/import"))),
147 + .offering(Act::new("Import samples", Action::get("/import/open"))),
148 148 )
149 149 } else {
150 150 Slot::new(BODY, RegionKind::Pane).with(Node::Table {
@@ -1,7 +1,104 @@
1 - //! The Quick-Import preflight, described: what is about to be indexed, and
2 - //! whether to ask again.
1 + //! Importing, described: the question asked before one starts, and every stage
2 + //! after it does.
3 3 //!
4 - //! Small, and it carries the finding this pass exists to have found.
4 + //! Two passes in one module, and the second one is why. It landed as the
5 + //! Quick-Import preflight alone, whose header said the wizard screens "are their
6 + //! own pass, and this trait is what that pass grows". They are grown here rather
7 + //! than filed beside, because the preflight and the flow are one subject asked at
8 + //! two moments: whether to import, and then how it is going. A second module
9 + //! would have made [`Importing`](super::Importing) two capabilities over one
10 + //! `import_wf`, which is the split [`Bulk`](super::Bulk) and [`Detail`](super::Detail)
11 + //! took for a reason this has not got — they narrow *differently* over one
12 + //! selection, and these do not narrow at all.
13 + //!
14 + //! # The flow: nine stages, one address
15 + //!
16 + //! [`export`](super::export)'s rule, applied rather than discovered: which screen
17 + //! is showing is a fact about the app, so `GET /import` answers whichever of the
18 + //! nine [`Stage`](super::Stage)s the flow is at, and nothing navigates between
19 + //! them. What is new is only the size — nine against five, and thirty-one routes
20 + //! against five — and the size is the app's: `ImportMode` carries all nine arms
21 + //! and `ui::import_screens` draws one screen per arm.
22 + //!
23 + //! Three of the shipped file's screens are **not** stages of this flow, and
24 + //! saying so is part of the port:
25 + //!
26 + //! - `draw_cleanup_progress` sweeps orphaned samples. Started by a bulk delete
27 + //! and by the export flow, reachable from nothing in the wizard, and drawn in
28 + //! `progress.rs` only because that file collects progress bars. Its own
29 + //! address, `/cleanup`. See [`Sweep`](super::Sweep).
30 + //! - `draw_operation_cancelled` serves three operations, and the export one is
31 + //! [`Phase::Cancelled`](super::Phase::Cancelled) already. [`Halted`](super::Halted)
32 + //! is `CancelKind` less that arm.
33 + //! - `ImportMode::ReviewLibrary` is on the same enum and is a different screen
34 + //! entirely.
35 + //!
36 + //! # THE FINDING: a stage rail is not a tab strip, and the vocabulary has one shape for both
37 + //!
38 + //! `WIZARD_STEPS` is four names — Configure, Tag folders, Analyze, Review — drawn
39 + //! on every screen of the flow with the current one marked, so "where am I / how
40 + //! much is left" never vanishes mid-flow. Four of the screens ported here draw
41 + //! it.
42 + //!
43 + //! [`Slot::showing_one`](quasi_router::Slot::showing_one) is the near miss, and
44 + //! it is near enough to be worth writing down why it was refused. It says
45 + //! *exactly one of these named children is up*, it carries the index and the
46 + //! per-child labels, and `makeover-layout`'s own note says a renderer draws
47 + //! labelled children as "a strip of the labels, the current one marked" — which
48 + //! is the rail, described. What it also says is
49 + //! [`Showing::selective`](https://docs.rs/makeover-layout): "whether the reader
50 + //! can change which child is up", true for `One`. A carousel and a tab group are
51 + //! what the member is for, and both are the reader's. **A wizard's stage is the
52 + //! app's.** The shipped code is explicit about it — "the wizard has no
53 + //! cross-step navigation post-Skip" — and a renderer told `showing_one` would
54 + //! offer a strip that navigates to a stage the flow is not at, whose region is
55 + //! empty because nothing has happened there yet.
56 + //!
57 + //! So the gap is one bit: **who chooses which child is up.** Filed as
58 + //! `quasi:vocabulary:unchosen-stage`, with four measured consumers in this one
59 + //! pass, and it degrades the way the token chips did — the description keeps the
60 + //! fact and loses the affordance, as `Step 2 of 4: Tag folders` in prose.
61 + //!
62 + //! # THE SECOND FINDING: `Act::disabled` carries no reason, and `Choice::unless` does
63 + //!
64 + //! [`Choice::unless`](quasi_router::Choice::unless) is "the same option, not
65 + //! pickable yet, **and why**". [`Act::disabled`](quasi_router::Act::disabled) is
66 + //! the same fact about a control with the why left out, so every renderer draws a
67 + //! dead button and the reader is left to work out what would wake it.
68 + //!
69 + //! This flow disables four controls and the shipped screen explains all four in
70 + //! an `on_disabled_hover_text`: Import until a new vault has a name, Cancel until
71 + //! the walk finishes, Apply Tags until something is typed, Apply N Tags until
72 + //! something is accepted. Filed as `quasi:vocabulary:disabled-reason`. Where the
73 + //! reason is already a field's [`error`](quasi_router::Field::error) it is not
74 + //! lost — that is the configure screen — and where it is not, it is an extra
75 + //! prose line, which is the degradation.
76 + //!
77 + //! # What is not describable, and it is three things
78 + //!
79 + //! | The shipped screen does | Described | Why not |
80 + //! |---|---|---|
81 + //! | invalid-tag warning per folder | yes | `tags::validate_tag` over what was typed, and pure |
82 + //! | BPM range and top keys over the batch | yes | arithmetic over the results the screen already carries |
83 + //! | **rate and ETA** | no | a rolling wall-clock buffer, which is a fact about this machine |
84 + //! | **the four native pickers** | no | `quasi:vocabulary:host-save-location`, consumers five to eight |
85 + //! | **↑/↓ walking the review list** | no | a key bound to "the next row of a list", which `Chrome` cannot say |
86 + //!
87 + //! The pickers are the sharper half. `Import folder...`, `Quick import
88 + //! folder...`, `Import files...` and `Change...` each open a native dialog and
89 + //! then act, which is the gap `integrity` filed as its fourth consumer. **Four
90 + //! more arrive here at once**, and with them a second half of the same gap that
91 + //! nothing had hit yet: a route that hands off to the host has nothing true to
92 + //! answer. [`Outcome`](quasi_router::Outcome) is `Screen`, `Fragment`, `Goto` or
93 + //! `Over`, and none of them is "nothing here changed". They answer
94 + //! `Goto(/import)` — right by the time the picker returns, and a frame early,
95 + //! which is the standing cost `Runtime::reload` corrects.
96 + //!
97 + //! # The preflight
98 + //!
99 + //! What follows is the first pass, unchanged. Its finding is unaffected by the
100 + //! flow arriving: an unprompted overlay is still unsayable, and the door the
101 + //! header promised the import act would own is [`open`](fn@open) below.
5 102 //!
6 103 //! # THE FINDING: nothing in the description raises an overlay
7 104 //!
@@ -45,29 +142,117 @@
45 142 //! decide whether there is anything to confirm, and that is the app deciding
46 143 //! when to ask rather than a fact about the question.
47 144
48 - use quasi_router::layout::{FieldKind, Tone};
145 + use quasi_router::layout::{FieldKind, Readiness, Selector, Tone};
49 146 use quasi_router::{
50 - Act, Action, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen,
51 - Slot,
147 + Act, Action, Choice, Field, Figure, Meter, Node, Outcome, Prose, RegionKind, Request, Response,
148 + RouteError, Router, Row, Screen, Slot,
52 149 };
53 150
54 - use super::Panels;
151 + use super::{
152 + Decision, Failure, FolderTags, Halted, Measure, Measures, Order, Panels, Reviewed, Stage,
153 + Strategy, Sweep, VaultChoice,
154 + };
55 155
56 156 /// The region the preflight answers into.
57 157 const BODY: &str = "import-preflight";
58 158
159 + /// The region the whole flow answers into.
160 + ///
161 + /// One region for nine screens, and [`export`](super::export)'s reason holds
162 + /// here with an extra edge to it: the flow does not only fail to navigate
163 + /// backwards, it has a transition it *cannot* walk back over. Configure to
164 + /// Importing is one-way — files already copied stay in the library — and the
165 + /// shipped screen says so in a line above the button. A region per stage would
166 + /// have implied a trail that does not exist.
167 + const FLOW: &str = "import-body";
168 +
169 + /// The region the sweep answers into.
170 + const SWEEP: &str = "cleanup-body";
171 +
172 + /// The list half of the review screen.
173 + const REVIEW_LIST: &str = "review-samples";
174 +
175 + /// The reading half of the review screen.
176 + const REVIEW_ITEM: &str = "review-item";
177 +
59 178 /// The name the "don't ask again" answer is submitted under.
60 179 const AGAIN: &str = "again";
61 180
181 + /// The name a folder's tags are submitted under.
182 + const TAGS: &str = "tags";
183 +
184 + /// The name a judged suggestion rides under.
185 + const TAG: &str = "tag";
186 +
62 187 /// Where an answered preflight goes.
63 188 const BACK: &str = "/";
64 189
65 - /// Register the preflight's routes.
190 + /// Where a door goes once the host has been asked.
191 + ///
192 + /// The flow, which is right by the time the picker answers and one frame early
193 + /// when it is pressed. See the module header on why there is nothing better to
194 + /// say: no [`Outcome`] means "nothing here changed".
195 + const FLOW_HOME: &str = "/import";
196 +
197 + /// The four names the shipped wizard rails, in order.
198 + ///
199 + /// Kept as the flow's own fact rather than read off `ui::import_screens`: the
200 + /// rail is what the reader is told about the shape of the flow, and that is a
201 + /// described thing even while the way of drawing it is not. See the module
202 + /// header, `quasi:vocabulary:unchosen-stage`.
203 + const STEPS: [&str; 4] = ["Configure", "Tag folders", "Analyze", "Review"];
204 +
205 + /// Register the preflight's routes, and the flow's.
66 206 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
67 - router
207 + let router = router
68 208 .get("/import/preflight", screen)
69 209 .post("/import/preflight", accept)
70 - .post("/import/preflight/cancel", cancel)
210 + .post("/import/preflight/cancel", cancel);
211 +
212 + let router = router
213 + .get("/import", index)
214 + .get("/import/open", open)
215 + .post("/import/open/folder", open_folder)
216 + .post("/import/open/quick", open_quickly)
217 + .post("/import/open/files", open_files)
218 + .post("/import/source", change_source)
219 + .post("/import/set/{decision}", decide)
220 + .post("/import/start", start)
221 + .post("/import/stop", stop)
222 + .post("/import/retry", retry)
223 + .post("/import/dismiss", dismiss);
224 +
225 + let router = router
226 + .post("/import/folders/all", tag_every_folder)
227 + .post("/import/folders/apply", apply_folder_tags)
228 + .post("/import/folders/skip", skip_folder_tags)
229 + .post("/import/folders/{at}/tags", tag_folder);
230 +
231 + let router = router
232 + .post("/import/measure/{measure}", measure)
233 + .post("/import/analyse", analyse)
234 + .post("/import/analyse/back", back_to_tagging)
235 + .post("/import/analyse/skip", skip_analysis)
236 + .post("/import/analyse/stop", stop_analysis)
237 + .post("/import/analyse/retry", retry_analysis);
238 +
239 + let router = router
240 + .post("/import/review/order", order)
241 + .post("/import/review/all/accept", accept_all)
242 + .post("/import/review/all/reject", reject_all)
243 + .post("/import/review/apply", apply_suggestions)
244 + .post("/import/review/discard", discard_suggestions)
245 + .post("/import/review/{at}/read", read)
246 + .post("/import/review/{at}/judge", judge);
247 +
248 + let router = router
249 + .post("/import/summary/keep", keep_failed)
250 + .post("/import/summary/purge", purge_all)
251 + .post("/import/summary/{at}/purge", purge_one);
252 +
253 + router
254 + .get("/cleanup", sweeping)
255 + .post("/cleanup/stop", stop_sweep)
71 256 }
72 257
73 258 /// `GET /import/preflight`
@@ -132,3 +317,1209 @@
132 317 state.importing.cancel();
133 318 Ok(Response::from(Outcome::Goto(Action::get(BACK))))
134 319 }
320 +
321 + // --- the flow ---
322 +
323 + /// `GET /import`
324 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
325 + Ok(flow(state).into())
326 + }
327 +
328 + /// `GET /import/open`
329 + ///
330 + /// The Import menu, as an overlay. The shipped one is a popup anchored to the
331 + /// button that opened it, and this is the **second consumer** of the note
332 + /// [`toolbar`](super::toolbar)'s header left on that: `Outcome::Over` is
333 + /// app-modal, an anchored popover is not, and the difference is where a host
334 + /// draws it rather than what it holds.
335 + fn open(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
336 + let body = Slot::new("import-menu", RegionKind::Pane)
337 + .with(Node::page("Import"))
338 + // Each entry says what it does rather than what it is called, which is
339 + // the shipped popup's own correction: "Import folder..." means the
340 + // wizard and "Quick import" means the fast path, and the two used to be
341 + // told apart by a tooltip.
342 + .with(Node::Act(Act::new(
343 + "Import folder...",
344 + Action::post("/import/open/folder"),
345 + )))
346 + .with(Node::text(
347 + "Choose a folder, pick where it lands, then tag and analyse what came in.",
348 + ))
349 + .with(Node::Act(Act::new(
350 + "Quick import folder...",
351 + Action::post("/import/open/quick"),
352 + )))
353 + .with(Node::text(
354 + "Index a folder where it sits, with no strategy or tagging review.",
355 + ))
356 + .with(Node::Act(Act::new(
357 + "Import files...",
358 + Action::post("/import/open/files"),
359 + )))
360 + .with(Node::text(
361 + "Merge chosen files into the vault that is open.",
362 + ))
363 + .with(Node::Act(Act::new("Close", Action::get(BACK)).key("esc")));
364 +
365 + Ok(Response::over(Screen::sidebar_content("Import").with(body)))
366 + }
367 +
368 + /// `POST /import/open/folder`
369 + fn open_folder(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
370 + state.importing.open_folder();
371 + Ok(handed_off())
372 + }
373 +
374 + /// `POST /import/open/quick`
375 + fn open_quickly(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
376 + state.importing.open_quickly();
377 + Ok(handed_off())
378 + }
379 +
380 + /// `POST /import/open/files`
381 + fn open_files(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
382 + state.importing.open_files();
383 + Ok(handed_off())
384 + }
385 +
386 + /// `POST /import/source`
387 + ///
388 + /// Refused unless something is being configured: the source is what the
389 + /// configure screen is about, and there is no honest screen for changing the
390 + /// source of an import that is already copying.
391 + fn change_source(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
392 + if !matches!(state.importing.stage(), Stage::Configuring { .. }) {
393 + return Err(RouteError::not_found("nothing is being configured"));
394 + }
395 + state.importing.change_source();
396 + Ok(handed_off())
397 + }
398 +
399 + /// What a door answers, having asked the host.
400 + ///
401 + /// See the module header: no outcome says "nothing here changed", so it says
402 + /// where the answer will be once the picker returns.
403 + fn handed_off() -> Response {
404 + Response::from(Outcome::Goto(Action::get(FLOW_HOME)))
405 + }
406 +
407 + /// `POST /import/set/{decision}`
408 + ///
409 + /// One route for all three of the configure screen's answers, which is
410 + /// [`export`](super::export)'s arrangement and [`Decision`] is what closes the
411 + /// set so the route carries no second list of what it will name.
412 + fn decide(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
413 + let name = request.captures.require("decision")?;
414 + let decision =
415 + Decision::from_key(name).ok_or_else(|| RouteError::not_found("no such import setting"))?;
416 + let value = request
417 + .payload
418 + .get(name)
419 + .or_else(|| request.payload.get(Node::SELECTED))
420 + .unwrap_or_default();
421 + if decision == Decision::Strategy && Strategy::from_key(value).is_none() {
422 + return Err(RouteError::not_found("no such import strategy"));
423 + }
424 + state.importing.decide(decision, value);
425 + Ok(flow(state).into())
426 + }
427 +
428 + /// `POST /import/start`
429 + ///
430 + /// Refused where the shipped button is disabled, and it is the same two
431 + /// conditions: a new vault with no name has nowhere to put the files, and a
432 + /// merge with no vaults would index past the end of the list the picker was
433 + /// built from. A disabled control the reader could still reach by typing the
434 + /// address is not disabled.
435 + fn start(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
436 + let Stage::Configuring {
437 + strategy,
438 + vault_name,
439 + vaults,
440 + ..
441 + } = state.importing.stage()
442 + else {
443 + return Err(RouteError::not_found("nothing is being configured"));
444 + };
445 + if let Some(reason) = unready(strategy, &vault_name, &vaults) {
446 + return Err(RouteError::not_found(reason));
447 + }
448 + state.importing.begin();
449 + Ok(flow(state).into())
450 + }
451 +
452 + /// `POST /import/stop`
453 + fn stop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
454 + state.importing.stop();
455 + Ok(flow(state).into())
456 + }
457 +
458 + /// `POST /import/retry`
459 + fn retry(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
460 + state.importing.retry();
461 + Ok(flow(state).into())
462 + }
463 +
464 + /// `POST /import/dismiss`
465 + fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
466 + state.importing.dismiss();
467 + Ok(flow(state).into())
468 + }
469 +
470 + /// `POST /import/folders/{at}/tags`
471 + fn tag_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
472 + let at = folder_at(state, &request)?;
473 + let typed = request.payload.get(TAGS).unwrap_or_default();
474 + state.importing.tag_folder(at, typed);
475 + Ok(flow(state).into())
476 + }
477 +
478 + /// `POST /import/folders/all`
479 + ///
480 + /// The shipped screen's "Apply to all", which copies one typed string into
481 + /// every folder's own input. Described as a write to all of them rather than as
482 + /// a field with its own life: `tag_folders_apply_all_input` is a `String` on
483 + /// `BrowserState` that exists to be typed into and cleared, and what is being
484 + /// typed into a described screen is the runtime's. Same deletion the preflight's
485 + /// checkbox made.
486 + fn tag_every_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
487 + let typed = request.payload.get(TAGS).unwrap_or_default().trim();
488 + if typed.is_empty() {
489 + return Err(RouteError::not_found("type at least one tag to apply"));
490 + }
491 + state.importing.tag_every_folder(typed);
492 + Ok(flow(state).into())
493 + }
494 +
495 + /// `POST /import/folders/apply`
496 + ///
497 + /// Refused where every input is empty, which is what the shipped button is
498 + /// disabled on and for its stated reason: applying nothing is Skip wearing
499 + /// another label, and Skip is the explicit discard path.
500 + fn apply_folder_tags(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
501 + let Stage::Tagging { folders } = state.importing.stage() else {
502 + return Err(RouteError::not_found("no folders are being tagged"));
503 + };
504 + if folders.iter().all(|folder| folder.typed.trim().is_empty()) {
505 + return Err(RouteError::not_found("nothing has been typed to apply"));
506 + }
507 + state.importing.apply_folder_tags();
508 + Ok(flow(state).into())
509 + }
510 +
511 + /// `POST /import/folders/skip`
512 + fn skip_folder_tags(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
513 + state.importing.skip_folder_tags();
514 + Ok(flow(state).into())
515 + }
516 +
517 + /// `POST /import/measure/{measure}`
518 + fn measure(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
519 + let name = request.captures.require("measure")?;
520 + let measure =
521 + Measure::from_key(name).ok_or_else(|| RouteError::not_found("no such measure"))?;
522 + // A checkbox submits its value or nothing, and non-empty is on. The reading
523 + // `export`'s `Setting::Sidecar` makes, rather than a literal "on" this side
524 + // would then have to agree with three renderers about.
525 + let wanted = !request.payload.get(name).unwrap_or_default().is_empty();
526 + state.importing.measure(measure, wanted);
527 + Ok(flow(state).into())
528 + }
529 +
530 + /// `POST /import/analyse`
531 + fn analyse(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
532 + state.importing.analyse();
533 + Ok(flow(state).into())
534 + }
535 +
536 + /// `POST /import/analyse/back`
537 + ///
538 + /// Refused where the tags of the previous step were never stashed, which is what
539 + /// the shipped Back button is disabled on: the flow was entered somewhere other
540 + /// than a folder import and there is no tagging step behind it.
541 + fn back_to_tagging(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
542 + let Stage::Choosing { resumable, .. } = state.importing.stage() else {
543 + return Err(RouteError::not_found("no analysis is being configured"));
544 + };
545 + if !resumable {
546 + return Err(RouteError::not_found(
547 + "there is no tagging step to go back to",
548 + ));
549 + }
550 + state.importing.back_to_tagging();
551 + Ok(flow(state).into())
552 + }
553 +
554 + /// `POST /import/analyse/skip`
555 + fn skip_analysis(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
556 + state.importing.skip_analysis();
557 + Ok(flow(state).into())
558 + }
559 +
560 + /// `POST /import/analyse/stop`
561 + fn stop_analysis(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
562 + state.importing.stop_analysis();
563 + Ok(flow(state).into())
564 + }
565 +
566 + /// `POST /import/analyse/retry`
567 + fn retry_analysis(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
568 + state.importing.retry_analysis();
569 + Ok(flow(state).into())
570 + }
571 +
572 + /// `POST /import/review/order`
573 + fn order(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
574 + let chosen = request
575 + .payload
576 + .get(Node::SELECTED)
577 + .or_else(|| request.payload.get("order"))
578 + .unwrap_or_default();
579 + let order = Order::from_key(chosen).ok_or_else(|| RouteError::not_found("no such order"))?;
580 + state.importing.order(order);
581 + Ok(flow(state).into())
582 + }
583 +
584 + /// `POST /import/review/{at}/read`
585 + fn read(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
Lines truncated
@@ -600,6 +600,71 @@
600 600 AcceptImport { again: bool },
601 601 /// Drop the import that is waiting.
602 602 CancelImport,
603 + /// Ask the host for a folder, then set the wizard up on it.
604 + OpenImportFolder,
605 + /// Ask the host for a folder, then index it with no questions asked.
606 + OpenQuickImport,
607 + /// Ask the host for files, then merge them into the vault that is open.
608 + OpenImportFiles,
609 + /// Ask the host for a different folder for the import being configured.
610 + ChangeImportSource,
611 + /// Answer one of the configure screen's three questions.
612 + Decide(Decision, String),
613 + /// Start copying the files in.
614 + BeginImport,
615 + /// Give up on the copy that is running.
616 + StopImport,
617 + /// Give up on it and go back to configuring.
618 + RetryImport,
619 + /// Put the import flow away, from wherever it is.
620 + DismissImport,
621 + /// Type these tags against this imported folder.
622 + TagFolder(usize, String),
623 + /// Type these tags against every imported folder.
624 + TagEveryFolder(String),
625 + /// Apply what was typed against the folders.
626 + ApplyFolderTags,
627 + /// Apply none of it and move on.
628 + SkipFolderTags,
629 + /// Turn one analysis measure on or off.
630 + Measure(Measure, bool),
631 + /// Run the analysis.
632 + StartAnalysis,
633 + /// Go back to tagging the imported folders.
634 + BackToTagging,
635 + /// Do not analyse at all.
636 + SkipAnalysis,
637 + /// Give up on the analysis that is running.
638 + StopAnalysis,
639 + /// Give up on it and start it again.
640 + RetryAnalysis,
641 + /// Order the review list this way.
642 + OrderReview(Order),
643 + /// Read this reviewed sample.
644 + ReadReviewed(usize),
645 + /// Accept or reject one suggestion against one sample.
646 + Judge {
647 + /// Which sample, as an index into the review list.
648 + at: usize,
649 + /// Which suggestion, by the tag it proposes.
650 + tag: String,
651 + /// Whether it is now accepted.
652 + accepted: bool,
653 + },
654 + /// Accept or reject every suggestion against every sample.
655 + JudgeAll(bool),
656 + /// Apply the accepted suggestions.
657 + ApplySuggestions,
658 + /// Apply none of them.
659 + DiscardSuggestions,
660 + /// Keep every file that failed.
661 + KeepFailed,
662 + /// Delete the ones that failed analysis, or one of them.
663 + PurgeFailed(Option<usize>),
664 + /// Give up on the sweep that is running.
665 + StopSweep,
666 + /// Open the export flow on whatever is selected.
667 + BeginExport,
603 668 /// Put the loose-files warning away without acting.
604 669 DismissLooseFiles,
605 670 /// Delete the registry entries whose files are gone.
@@ -963,6 +1028,9 @@
963 1028 /// Where the flow has got to.
964 1029 fn phase(&self) -> Phase;
965 1030
1031 + /// Open the flow on whatever is chosen. See [`export`]'s `begin`.
1032 + fn open(&self);
1033 +
966 1034 /// Change one setting.
967 1035 fn configure(&self, setting: Setting, value: &str);
968 1036
@@ -985,6 +1053,10 @@
985 1053 }
986 1054
987 1055 impl Export for FromExport<'_> {
1056 + fn open(&self) {
1057 + self.intents.borrow_mut().push(Intent::BeginExport);
1058 + }
1059 +
988 1060 fn phase(&self) -> Phase {
989 1061 use crate::state::ImportMode;
990 1062
@@ -2568,6 +2640,468 @@
2568 2640 }
2569 2641 }
2570 2642
2643 + /// Where the import flow has got to.
2644 + ///
2645 + /// [`Phase`]'s peer for the other long flow, and the same argument holds for the
2646 + /// same reason: which screen is showing is a fact about the app rather than
2647 + /// somewhere the user chose to be, so it is one address answering several
2648 + /// screens. What is different is the length — nine states against five — and the
2649 + /// length is the app's, not the description's: `ImportMode` carries all nine and
2650 + /// the shipped wizard draws one screen per state.
2651 + ///
2652 + /// **Not every state on `ImportMode` is here.** The four export states are
2653 + /// [`Phase`]'s, and `Cleaning` and `ReviewLibrary` are not import at all: the
2654 + /// enum is the app's full-screen router rather than an import-only state, which
2655 + /// its own header says. Splitting it here is the description saying what the
2656 + /// name stopped saying — see [`Sweep`] for the one that shares a *file* with
2657 + /// these screens and nothing else.
2658 + #[derive(Debug, Clone, PartialEq)]
2659 + pub enum Stage {
2660 + /// Nothing is being imported.
2661 + Idle,
2662 + /// Choosing what lands where, before anything is copied.
2663 + Configuring {
2664 + /// The folder the files are coming from.
2665 + source: String,
2666 + /// How many audio files the dry-run scan found in it.
2667 + files: usize,
2668 + /// Where they will land.
2669 + strategy: Strategy,
2670 + /// The name typed for a new vault.
2671 + vault_name: String,
2672 + /// The vaults a merge could go into.
2673 + vaults: Vec<VaultChoice>,
2674 + /// Which of them is chosen.
2675 + merging_into: usize,
2676 + },
2677 + /// Walking the folder, before the count is known.
2678 + ///
2679 + /// Its own state rather than [`Copying`](Self::Copying) with a flag, for
2680 + /// the reason [`Phase`] is an enum: there is no total to report yet, and a
2681 + /// screen holding a total that is not there is one every reader has to ask
2682 + /// about.
2683 + Scanning {
2684 + /// How many audio files the walk has reached, or zero before the first
2685 + /// event lands.
2686 + found: usize,
2687 + /// How much they weigh, as the app formats a size.
2688 + size: Option<String>,
2689 + },
2690 + /// Files being copied in.
2691 + Copying {
2692 + /// How many have landed.
2693 + done: usize,
2694 + /// How many there are.
2695 + total: usize,
2696 + /// The one being copied now.
2697 + current: String,
2698 + /// What the whole set weighs, as the app formats a size.
2699 + size: Option<String>,
2700 + /// Whether the files are referenced where they sit rather than copied.
2701 + in_place: bool,
2702 + /// What has gone wrong so far.
2703 + failures: Vec<Failure>,
2704 + },
2705 + /// Naming what came in, one folder at a time.
2706 + Tagging {
2707 + /// Every imported folder, with whatever has been typed against it.
2708 + folders: Vec<FolderTags>,
2709 + },
2710 + /// Choosing what to measure, before any of it runs.
2711 + Choosing {
2712 + /// How many samples would be analysed.
2713 + samples: usize,
2714 + /// What is ticked.
2715 + measures: Measures,
2716 + /// Whether the tagging step can be returned to.
2717 + resumable: bool,
2718 + },
2719 + /// Samples being analysed.
2720 + Analysing {
2721 + /// How many are done.
2722 + done: usize,
2723 + /// How many there are.
2724 + total: usize,
2725 + /// The one being analysed now.
2726 + current: String,
2727 + /// What has gone wrong so far, from both halves of the run.
2728 + failures: Vec<Failure>,
2729 + },
2730 + /// Reading what the analysis suggested, before any of it is applied.
2731 + Reviewing {
2732 + /// Every sample with something to say about it, in the app's own order.
2733 + items: Vec<Reviewed>,
2734 + /// Which one is being read, as an index into `items`.
2735 + at: usize,
2736 + /// How the list is ordered.
2737 + order: Order,
2738 + },
2739 + /// What failed, once the run is over.
2740 + Summary {
2741 + /// Files that never entered the library.
2742 + rejected: Vec<Failure>,
2743 + /// Files that entered it and could not be analysed.
2744 + unanalysed: Vec<Failure>,
2745 + },
2746 + /// Given up on partway.
2747 + Stopped {
2748 + /// Which half of the flow stopped.
2749 + what: Halted,
2750 + /// How many had been done when it stopped.
2751 + done: usize,
2752 + /// How many there would have been.
2753 + total: usize,
2754 + },
2755 + }
2756 +
2757 + /// Where imported files land.
2758 + ///
2759 + /// Mirrored rather than re-exported, which is [`Format`]'s reason: the app's
2760 + /// `ImportStrategy` carries the vault and parent ids the choice resolves to, and
2761 + /// those are the answer rather than the question. What the screen asks is which
2762 + /// of three, and that is this.
2763 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2764 + pub enum Strategy {
2765 + /// Every file into the folder that is open, structure discarded.
2766 + Flat,
2767 + /// A new vault, with the folder tree preserved.
2768 + NewVault,
2769 + /// An existing vault, merged into.
2770 + Merge,
2771 + }
2772 +
2773 + impl Strategy {
2774 + /// The name a described control submits.
2775 + #[must_use]
2776 + pub const fn as_str(self) -> &'static str {
2777 + match self {
2778 + Self::Flat => "flat",
2779 + Self::NewVault => "new",
2780 + Self::Merge => "merge",
2781 + }
2782 + }
2783 +
2784 + /// The strategy that name means, if it means one.
2785 + #[must_use]
2786 + pub fn from_key(name: &str) -> Option<Self> {
2787 + match name {
2788 + "flat" => Some(Self::Flat),
2789 + "new" => Some(Self::NewVault),
2790 + "merge" => Some(Self::Merge),
2791 + _ => None,
2792 + }
2793 + }
2794 + }
2795 +
2796 + /// A vault a merge could go into.
2797 + ///
2798 + /// The name and nothing else: the shipped picker addresses one by its index into
2799 + /// the list it was built from, and that is what the app's `selected_merge_vfs_idx`
2800 + /// holds. Carrying the id as well would offer the description a second way to
2801 + /// name the same thing, and the app can only read one of them.
2802 + #[derive(Debug, Clone, PartialEq, Eq)]
2803 + pub struct VaultChoice {
2804 + /// What the vault is called.
2805 + pub name: String,
2806 + }
2807 +
2808 + /// Something that went wrong, as a screen reports it.
2809 + ///
2810 + /// One type for both error lists, where the app has two — `ImportFileError`
2811 + /// carries a path and `AnalysisFileError` carries a hash and a name. What a
2812 + /// screen says of either is the same two things, so the difference is which list
2813 + /// it is in rather than what shape it has.
2814 + #[derive(Debug, Clone, PartialEq, Eq)]
2815 + pub struct Failure {
2816 + /// What it was, as the app names it: a path before the store, a name after.
2817 + pub name: String,
2818 + /// Why it failed.
2819 + pub error: String,
2820 + }
2821 +
2822 + /// One imported folder waiting to be tagged.
2823 + #[derive(Debug, Clone, PartialEq, Eq)]
2824 + pub struct FolderTags {
2825 + /// What the folder is called.
2826 + pub name: String,
2827 + /// How many samples came out of it.
2828 + pub samples: usize,
2829 + /// What has been typed against it, comma-separated.
2830 + pub typed: String,
2831 + /// The typed tags this app would refuse, if any.
2832 + ///
2833 + /// Resolved here rather than in the route because it is the app's rule:
2834 + /// `audiofiles_core::tags::validate_tag` says what a tag may be, and a
2835 + /// described screen carrying a second copy of it would be the drift this
2836 + /// layer exists to end. Same division as [`panel`]'s `add_tag`.
2837 + pub invalid: Vec<String>,
2838 + }
2839 +
2840 + /// What an analysis run would measure.
2841 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2842 + pub struct Measures {
2843 + /// Peak, RMS and LUFS.
2844 + pub loudness: bool,
2845 + /// Tempo.
2846 + pub bpm: bool,
2847 + /// Musical key.
2848 + pub key: bool,
2849 + /// Centroid, flatness, rolloff and zero-crossing rate.
2850 + pub spectral: bool,
2851 + /// Whether the sample is a seamless loop.
2852 + pub loops: bool,
2853 + /// Tags suggested from the results.
2854 + pub suggestions: bool,
2855 + /// The envelope fingerprint near-duplicate detection reads.
2856 + pub fingerprint: bool,
2857 + /// Skipping tempo and key where they cannot apply.
2858 + pub smart_skip: bool,
2859 + }
2860 +
2861 + /// One thing an analysis run may be told to measure.
2862 + ///
2863 + /// [`Setting`]'s peer, and closed for the same reason: it is what lets one write
2864 + /// route serve the whole screen without carrying a second list of the names it
2865 + /// will answer to.
2866 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2867 + pub enum Measure {
2868 + /// [`Measures::loudness`].
2869 + Loudness,
2870 + /// [`Measures::bpm`].
2871 + Bpm,
2872 + /// [`Measures::key`].
2873 + Key,
2874 + /// [`Measures::spectral`].
2875 + Spectral,
2876 + /// [`Measures::loops`].
2877 + Loops,
2878 + /// [`Measures::suggestions`].
2879 + Suggestions,
2880 + /// [`Measures::fingerprint`].
2881 + Fingerprint,
2882 + /// [`Measures::smart_skip`].
2883 + SmartSkip,
2884 + }
2885 +
2886 + impl Measure {
2887 + /// Every one of them, in the order the shipped screen ticks them.
2888 + pub const ALL: [Self; 8] = [
2889 + Self::Loudness,
2890 + Self::Bpm,
2891 + Self::Key,
2892 + Self::Spectral,
2893 + Self::Loops,
2894 + Self::Suggestions,
2895 + Self::Fingerprint,
2896 + Self::SmartSkip,
2897 + ];
2898 +
2899 + /// The name a described address is built from.
2900 + #[must_use]
2901 + pub const fn as_str(self) -> &'static str {
2902 + match self {
2903 + Self::Loudness => "loudness",
2904 + Self::Bpm => "bpm",
2905 + Self::Key => "key",
2906 + Self::Spectral => "spectral",
2907 + Self::Loops => "loops",
2908 + Self::Suggestions => "suggestions",
2909 + Self::Fingerprint => "fingerprint",
2910 + Self::SmartSkip => "smart-skip",
2911 + }
2912 + }
2913 +
2914 + /// What the control says.
2915 + #[must_use]
2916 + pub const fn label(self) -> &'static str {
2917 + match self {
2918 + Self::Loudness => "Loudness (peak, RMS, LUFS)",
2919 + Self::Bpm => "BPM detection",
2920 + Self::Key => "Key detection",
2921 + Self::Spectral => "Spectral features",
2922 + Self::Loops => "Loop detection",
2923 + Self::Suggestions => "Auto-suggest tags",
2924 + Self::Fingerprint => "Fingerprint (duplicate detection)",
2925 + Self::SmartSkip => "Smart skip (skip BPM/key where they cannot apply)",
2926 + }
2927 + }
2928 +
2929 + /// The measure that name means, if it means one.
2930 + #[must_use]
2931 + pub fn from_key(name: &str) -> Option<Self> {
2932 + Self::ALL.into_iter().find(|held| held.as_str() == name)
2933 + }
2934 +
2935 + /// Whether this one is on.
2936 + #[must_use]
2937 + pub const fn read(self, measures: &Measures) -> bool {
2938 + match self {
2939 + Self::Loudness => measures.loudness,
2940 + Self::Bpm => measures.bpm,
2941 + Self::Key => measures.key,
2942 + Self::Spectral => measures.spectral,
2943 + Self::Loops => measures.loops,
2944 + Self::Suggestions => measures.suggestions,
2945 + Self::Fingerprint => measures.fingerprint,
2946 + Self::SmartSkip => measures.smart_skip,
2947 + }
2948 + }
2949 + }
2950 +
2951 + /// One analysed sample, and what the analysis wants to call it.
2952 + #[derive(Debug, Clone, PartialEq)]
2953 + pub struct Reviewed {
2954 + /// What the sample is called.
2955 + pub name: String,
2956 + /// How long it runs, in seconds.
2957 + pub duration: f64,
2958 + /// What it was recorded at.
2959 + pub sample_rate: u32,
2960 + /// Its peak, in dBFS.
2961 + pub peak_db: Option<f64>,
2962 + /// Its tempo, where one was found.
2963 + pub bpm: Option<f64>,
2964 + /// Its key, where one was found.
2965 + pub musical_key: Option<String>,
2966 + /// What the analysis suggests, best first.
2967 + ///
2968 + /// Sorted by the description rather than by the app, which is the one place
2969 + /// this flow reorders anything: the shipped screen sorts `item.suggestions`
2970 + /// in place every frame, and a route cannot do that. Sorting a copy is the
2971 + /// same answer without the write.
2972 + pub suggestions: Vec<Suggestion>,
2973 + }
2974 +
2975 + /// One tag the analysis proposes.
2976 + #[derive(Debug, Clone, PartialEq)]
2977 + pub struct Suggestion {
2978 + /// The tag itself.
2979 + pub tag: String,
2980 + /// How sure the analysis is, from zero to one.
2981 + pub confidence: f32,
2982 + /// Why it thinks so.
2983 + pub reason: String,
2984 + /// Whether it is ticked to be applied.
2985 + pub accepted: bool,
2986 + }
2987 +
2988 + /// How the review list is ordered.
2989 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2990 + pub enum Order {
2991 + /// The order they were imported in.
2992 + Arrival,
2993 + /// By name.
2994 + Name,
2995 + /// Most suggestions first.
2996 + Suggestions,
2997 + /// Most accepted first.
2998 + Accepted,
2999 + }
3000 +
3001 + impl Order {
3002 + /// Every one of them, in the order the shipped picker lists them.
3003 + pub const ALL: [Self; 4] = [Self::Arrival, Self::Name, Self::Suggestions, Self::Accepted];
3004 +
3005 + /// The name a described control submits.
3006 + #[must_use]
3007 + pub const fn as_str(self) -> &'static str {
3008 + match self {
3009 + Self::Arrival => "arrival",
3010 + Self::Name => "name",
3011 + Self::Suggestions => "suggestions",
3012 + Self::Accepted => "accepted",
3013 + }
3014 + }
3015 +
3016 + /// What the control says.
3017 + #[must_use]
3018 + pub const fn label(self) -> &'static str {
3019 + match self {
3020 + Self::Arrival => "Import order",
3021 + Self::Name => "Name",
3022 + Self::Suggestions => "Suggestions",
3023 + Self::Accepted => "Accepted",
3024 + }
3025 + }
3026 +
3027 + /// The order that name means, if it means one.
3028 + #[must_use]
3029 + pub fn from_key(name: &str) -> Option<Self> {
3030 + Self::ALL.into_iter().find(|held| held.as_str() == name)
3031 + }
3032 + }
3033 +
3034 + /// One thing the configure screen may change.
3035 + ///
3036 + /// [`Setting`] and [`Measure`]'s third peer. Three answers rather than one,
3037 + /// because the strategy is derived from all three and the app rebuilds it from
3038 + /// them every frame — see `ui::import_screens::configure`, and the bug that
3039 + /// arrangement was written to fix.
3040 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3041 + pub enum Decision {
3042 + /// Which of the three strategies.
3043 + Strategy,
3044 + /// What to call a new vault.
3045 + VaultName,
3046 + /// Which existing vault to merge into.
3047 + MergeVault,
3048 + }
3049 +
Lines truncated
@@ -54,6 +54,8 @@
54 54 detail: Option<Runtime>,
55 55 shell: Option<Runtime>,
56 56 edit: Option<Runtime>,
57 + import: Option<Runtime>,
58 + sweep: Option<Runtime>,
57 59 /// Whether the described main window is open.
58 60 pub show_shell: bool,
59 61 /// Whether the described detail panel is open.
@@ -296,6 +298,66 @@
296 298 }
297 299 }
298 300
301 + /// Draw the described import flow, and act on whatever was pressed.
302 + ///
303 + /// **Refreshed unconditionally**, and it is the export flow's reason twice over:
304 + /// two of its nine stages are a worker copying files and a worker analysing
305 + /// them, so the screen moves with nothing pressed, and every control on the
306 + /// configure and tagging stages writes through an intent that lands after the
307 + /// answer was built.
308 + pub fn draw_import(ctx: &egui::Context, state: &mut BrowserState) {
309 + let intents = RefCell::new(Vec::new());
310 + let mut runtime = state.described.import.take();
311 + let host = Host {
312 + state,
313 + sync: None,
314 + themes: themes(),
315 + intents: &intents,
316 + };
317 + let closed = window(
318 + ctx,
319 + "Import (described)",
320 + &mut runtime,
321 + &host,
322 + "/import",
323 + true,
324 + );
325 + state.described.import = runtime;
326 + apply(ctx, state, intents.into_inner());
327 + if closed {
328 + state.described.import = None;
329 + }
330 + }
331 +
332 + /// Draw the described sweep, and act on whatever was pressed.
333 + ///
334 + /// Its own window rather than a stage of the import flow's, because it is not
335 + /// one: see `importing`'s header. Refreshed unconditionally for the plainest of
336 + /// the reasons -- a worker is removing rows and the count moves on its own.
337 + pub fn draw_sweep(ctx: &egui::Context, state: &mut BrowserState) {
338 + let intents = RefCell::new(Vec::new());
339 + let mut runtime = state.described.sweep.take();
340 + let host = Host {
341 + state,
342 + sync: None,
343 + themes: themes(),
344 + intents: &intents,
345 + };
346 + let closed = window(
347 + ctx,
348 + "Cleaning up (described)",
349 + &mut runtime,
350 + &host,
351 + "/cleanup",
352 + true,
353 + );
354 + state.described.sweep = runtime;
355 + apply(ctx, state, intents.into_inner());
356 + if closed {
357 + state.described.sweep = None;
358 + }
359 + }
360 +
299 361 /// Do what a described screen asked the app to do to itself.
300 362 ///
301 363 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -690,6 +752,188 @@
690 752 }
691 753 Intent::BatchGain(db) => state.batch_gain(db),
692 754 Intent::BatchReverse => state.batch_reverse(),
755 + // The import flow. Every one of these lands on `import_wf`, which
756 + // is the app's own screen state, so the whole capability writes
757 + // through intents -- see `Importing`'s header.
758 + //
759 + // The four doors are host acts with no described step, which is
760 + // `quasi:vocabulary:host-save-location`'s fifth to eighth consumers
761 + // and the same shape `LocateLooseFiles` takes below.
762 + Intent::OpenImportFolder => {
763 + state
764 + .dialogs
765 + .pick_folder("Import folder", BrowserState::show_import_options);
766 + }
767 + Intent::OpenQuickImport => {
768 + state
769 + .dialogs
770 + .pick_folder("Quick import folder", BrowserState::quick_import_folder);
771 + }
772 + Intent::OpenImportFiles => {
773 + state.dialogs.pick_files(
774 + "Import files",
775 + &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)],
776 + |state, paths| {
777 + // Batched through the worker rather than hashed on the
778 + // GUI thread, which is the shipped menu entry's own fix.
779 + if let Some(vfs_id) = state.current_vfs_id() {
780 + let strategy = crate::import::ImportStrategy::MergeIntoVfs {
781 + vfs_id,
782 + parent_id: state.nav.current_dir,
783 + };
784 + state.start_files_import(&paths, strategy);
785 + }
786 + },
787 + );
788 + }
789 + Intent::ChangeImportSource => {
790 + state
791 + .dialogs
792 + .pick_folder("Choose source folder", |state, folder| {
793 + state.change_import_source(folder);
794 + });
795 + }
796 + Intent::Decide(decision, value) => decide(state, decision, &value),
797 + Intent::BeginImport => begin_import(state),
798 + Intent::StopImport => state.cancel_import(),
799 + Intent::RetryImport => state.retry_import(),
800 + // Whatever stage it is at, the flow is over. The shipped screens
801 + // each write `ImportMode::None` at their own Cancel or Done, and
802 + // this is the one place the described side does.
803 + Intent::DismissImport => {
804 + state.import_wf.import_mode = crate::state::ImportMode::None;
805 + }
806 + Intent::TagFolder(at, typed) => {
807 + if let crate::state::ImportMode::TagFolders { entries, .. } =
808 + &mut state.import_wf.import_mode
809 + && let Some(entry) = entries.get_mut(at)
810 + {
811 + entry.tag_input = typed;
812 + }
813 + }
814 + Intent::TagEveryFolder(typed) => {
815 + if let crate::state::ImportMode::TagFolders { entries, .. } =
816 + &mut state.import_wf.import_mode
817 + {
818 + for entry in entries.iter_mut() {
819 + entry.tag_input.clone_from(&typed);
820 + }
821 + }
822 + }
823 + Intent::ApplyFolderTags => state.apply_folder_tags(),
824 + Intent::SkipFolderTags => state.skip_folder_tags(),
825 + Intent::Measure(measure, wanted) => {
826 + if let crate::state::ImportMode::ConfigureAnalysis { config, .. } =
827 + &mut state.import_wf.import_mode
828 + {
829 + match measure {
830 + super::Measure::Loudness => config.loudness = wanted,
831 + super::Measure::Bpm => config.bpm = wanted,
832 + super::Measure::Key => config.key = wanted,
833 + super::Measure::Spectral => config.spectral = wanted,
834 + super::Measure::Loops => config.loop_detect = wanted,
835 + super::Measure::Suggestions => config.auto_suggest_tags = wanted,
836 + super::Measure::Fingerprint => config.fingerprint = wanted,
837 + super::Measure::SmartSkip => config.smart_skip = wanted,
838 + }
839 + }
840 + }
841 + Intent::StartAnalysis => {
842 + if let crate::state::ImportMode::ConfigureAnalysis {
843 + sample_hashes,
844 + config,
845 + } = &state.import_wf.import_mode
846 + {
847 + let (hashes, config) = (sample_hashes.clone(), config.clone());
848 + state.run_analysis(hashes, config);
849 + }
850 + }
851 + Intent::BackToTagging => state.back_to_tag_folders(),
852 + Intent::SkipAnalysis => {
853 + state.import_wf.import_mode = crate::state::ImportMode::None;
854 + "Imported. Run analysis from the sidebar when ready.".clone_into(&mut state.status);
855 + }
856 + Intent::StopAnalysis => state.cancel_analysis(),
857 + Intent::RetryAnalysis => state.retry_analysis(),
858 + Intent::OrderReview(order) => {
859 + if let crate::state::ImportMode::ReviewSuggestions { sort, .. } =
860 + &mut state.import_wf.import_mode
861 + {
862 + *sort = match order {
863 + super::Order::Arrival => crate::state::ReviewSort::ImportOrder,
864 + super::Order::Name => crate::state::ReviewSort::Name,
865 + super::Order::Suggestions => crate::state::ReviewSort::Suggestions,
866 + super::Order::Accepted => crate::state::ReviewSort::Accepted,
867 + };
868 + }
869 + }
870 + Intent::ReadReviewed(at) => {
871 + if let crate::state::ImportMode::ReviewSuggestions {
872 + items, current_idx, ..
873 + } = &mut state.import_wf.import_mode
874 + && at < items.len()
875 + {
876 + *current_idx = at;
877 + }
878 + }
879 + // By tag rather than by position, which is the route's own reason:
880 + // the description sorts its copy by confidence and the app does not,
881 + // so an index agreed on one side is not the same row on the other.
882 + Intent::Judge { at, tag, accepted } => {
883 + if let crate::state::ImportMode::ReviewSuggestions { items, .. } =
884 + &mut state.import_wf.import_mode
885 + && let Some(item) = items.get_mut(at)
886 + && let Some(held) = item
887 + .suggestions
888 + .iter_mut()
889 + .find(|held| held.suggestion.tag == tag)
890 + {
891 + held.accepted = accepted;
892 + }
893 + }
894 + Intent::JudgeAll(accepted) => {
895 + if let crate::state::ImportMode::ReviewSuggestions { items, .. } =
896 + &mut state.import_wf.import_mode
897 + {
898 + for item in items.iter_mut() {
899 + for held in &mut item.suggestions {
900 + held.accepted = accepted;
901 + }
902 + }
903 + }
904 + }
905 + Intent::ApplySuggestions => state.apply_accepted_suggestions(),
906 + Intent::DiscardSuggestions => {
907 + state.import_wf.import_mode = crate::state::ImportMode::None;
908 + "Suggestions discarded".clone_into(&mut state.status);
909 + }
910 + Intent::KeepFailed => state.dismiss_import_errors(),
911 + // The described control asked with `Act::confirm` and the runtime
912 + // already had the answer, so `ConfirmAction` arrives here as an
913 + // argument carrier rather than as a question -- `library`'s header
914 + // made concrete for the third time.
915 + Intent::PurgeFailed(at) => {
916 + let name = at.and_then(|at| {
917 + state
918 + .import_wf
919 + .analysis_errors
920 + .get(at)
921 + .map(|failure| failure.name.clone())
922 + });
923 + let count = match at {
924 + Some(_) => 1,
925 + None => state.import_wf.analysis_errors.len(),
926 + };
927 + state.overlay.pending_confirm =
928 + Some(crate::state::ConfirmAction::RemoveFailedSamples {
929 + single_index: at,
930 + count,
931 + name,
932 + });
933 + state.execute_confirmed_action();
934 + }
935 + Intent::StopSweep => state.cancel_cleanup(),
936 + Intent::BeginExport => state.start_export_flow(None),
693 937 // The host act with no described step. See `integrity`'s header:
694 938 // fourth consumer of `quasi:vocabulary:host-save-location`.
695 939 Intent::LocateLooseFiles => {
@@ -767,6 +1011,126 @@
767 1011 .collect()
768 1012 }
769 1013
1014 + /// Write one described answer back into the import being configured.
1015 + ///
1016 + /// **The strategy is re-derived from all three answers on every write**, never
1017 + /// patched in place, and that is `ui::import_screens::configure`'s own
1018 + /// arrangement rather than a choice made here. The comment it carries names the
1019 + /// bug it fixed: the strategy is a function of the three answers, so a site that
1020 + /// changes one of them and forgets to rebuild it leaves a strategy that
1021 + /// disagrees with the controls, and the vault-name edit was the only site that
1022 + /// remembered.
1023 + ///
1024 + /// A strategy the app cannot form yet — flat with no vault open, merge with
1025 + /// nothing to merge into — leaves the previous one standing, which is what the
1026 + /// described radio then reads back from. That too is the shipped screen's.
1027 + fn decide(state: &mut BrowserState, decision: super::Decision, value: &str) {
1028 + use crate::import::ImportStrategy;
1029 +
1030 + let current_vfs_id = state.current_vfs_id();
1031 + let current_dir = state.nav.current_dir;
1032 + let crate::state::ImportMode::ConfigureImport {
1033 + strategy,
1034 + new_vfs_name,
1035 + available_vfs,
1036 + selected_merge_vfs_idx,
1037 + ..
1038 + } = &mut state.import_wf.import_mode
1039 + else {
1040 + // The flow moved on between the frame that drew the control and the one
1041 + // that applies it. Dropping the write is right for `configure`'s reason:
1042 + // there is no longer a configuration for it to land in.
1043 + return;
1044 + };
1045 +
1046 + let mut chosen = match strategy {
1047 + ImportStrategy::Flat { .. } => super::Strategy::Flat,
1048 + ImportStrategy::NewVfs { .. } => super::Strategy::NewVault,
1049 + ImportStrategy::MergeIntoVfs { .. } => super::Strategy::Merge,
1050 + };
1051 +
1052 + match decision {
1053 + // The route already refused anything else, so an unreadable strategy
1054 + // here is one the app grew and the description has not learned.
1055 + super::Decision::Strategy => chosen = super::Strategy::from_key(value).unwrap_or(chosen),
1056 + super::Decision::VaultName => {
1057 + new_vfs_name.clear();
1058 + new_vfs_name.push_str(value);
1059 + }
1060 + super::Decision::MergeVault => {
1061 + if let Ok(at) = value.parse::<usize>() {
1062 + *selected_merge_vfs_idx = at;
1063 + }
1064 + }
1065 + }
1066 +
1067 + let next = match chosen {
1068 + super::Strategy::Flat => current_vfs_id.map(|vfs_id| ImportStrategy::Flat {
1069 + vfs_id,
1070 + parent_id: current_dir,
1071 + }),
1072 + super::Strategy::NewVault => Some(ImportStrategy::NewVfs {
1073 + vfs_name: new_vfs_name.clone(),
1074 + }),
1075 + super::Strategy::Merge => {
1076 + available_vfs
1077 + .get(*selected_merge_vfs_idx)
1078 + .map(|vfs| ImportStrategy::MergeIntoVfs {
1079 + vfs_id: vfs.id,
1080 + parent_id: None,
1081 + })
1082 + }
1083 + };
1084 + if let Some(next) = next {
1085 + *strategy = next;
1086 + }
1087 + }
1088 +
1089 + /// Start the import that is configured, the way the shipped button does.
1090 + ///
1091 + /// The strategy is rebuilt from the three answers one last time rather than
1092 + /// taken as it stands, which is again the shipped button's own code: the vault
1093 + /// name and the merge index are what the user typed and picked, and the
1094 + /// strategy is only ever their derivative.
1095 + fn begin_import(state: &mut BrowserState) {
1096 + use crate::import::ImportStrategy;
1097 +
1098 + let crate::state::ImportMode::ConfigureImport {
1099 + source,
1100 + strategy,
1101 + new_vfs_name,
1102 + available_vfs,
1103 + selected_merge_vfs_idx,
1104 + ..
1105 + } = &state.import_wf.import_mode
1106 + else {
1107 + return;
1108 + };
1109 +
1110 + let source = source.clone();
1111 + let strategy = match strategy {
1112 + ImportStrategy::Flat { vfs_id, parent_id } => Some(ImportStrategy::Flat {
1113 + vfs_id: *vfs_id,
1114 + parent_id: *parent_id,
1115 + }),
1116 + ImportStrategy::NewVfs { .. } => Some(ImportStrategy::NewVfs {
1117 + vfs_name: new_vfs_name.clone(),
1118 + }),
1119 + ImportStrategy::MergeIntoVfs { .. } => {
1120 + available_vfs
1121 + .get(*selected_merge_vfs_idx)
1122 + .map(|vfs| ImportStrategy::MergeIntoVfs {
1123 + vfs_id: vfs.id,
1124 + parent_id: None,
1125 + })
1126 + }
1127 + };
1128 +
1129 + if let Some(strategy) = strategy {
1130 + state.start_folder_import(source, strategy);
1131 + }
1132 + }
1133 +
770 1134 /// Write one described setting back into the app's own export config.
771 1135 ///
772 1136 /// The described value is a string because that is what a control submits, and
@@ -13,11 +13,12 @@
13 13
14 14 use super::{
15 15 Analysed, Analysis, Bar, Bulk, Channels, Chosen, Collection, ColumnsShown, Config, Coverage,
16 - Crumb, Detail, Detailed, Editing, Export, Files, Filter, Focus, Folder, Format, Holding,
17 - Importing, Integrity, Library, Naming, Panel, Panels, Phase, Playing, Preflight, Pricing,
18 - ProfileChoice, Sample, Saying, Searching, Setting, Settings, Shared, Shell, Source, Spread,
19 - State, Status, Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, Vault, Where,
20 - router,
16 + Crumb, Decision, Detail, Detailed, Editing, Export, Failure, Files, Filter, Focus, Folder,
17 + FolderTags, Format, Halted, Holding, Importing, Integrity, Library, Measure, Measures, Naming,
18 + Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Reviewed, Sample,
19 + Saying, Searching, Setting, Settings, Shared, Shell, Source, Spread, Stage, State, Status,
20 + Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice,
21 + Vault, VaultChoice, Where, router,
21 22 };
22 23
23 24 /// A config store in memory.
@@ -132,6 +133,7 @@
132 133 fn phase(&self) -> Phase {
133 134 Phase::Idle
134 135 }
136 + fn open(&self) {}
135 137 fn configure(&self, _setting: Setting, _value: &str) {}
136 138 fn start(&self) {}
137 139 fn cancel(&self) {}
@@ -161,6 +163,9 @@
161 163 fn phase(&self) -> Phase {
162 164 self.phase.clone()
163 165 }
166 + fn open(&self) {
167 + self.asked.borrow_mut().push("open".to_owned());
168 + }
164 169 fn configure(&self, setting: Setting, value: &str) {
165 170 self.asked
166 171 .borrow_mut()
@@ -4289,16 +4294,54 @@
4289 4294 }
4290 4295 }
4291 4296
4292 - /// No import is waiting.
4297 + /// Nothing is being imported, and nothing can be.
4298 + ///
4299 + /// [`Idle`]'s peer for the other flow, and the same argument: every method is a
4300 + /// refusal, so a test of some other screen cannot start an import by accident.
4301 + /// A fake that recorded the call would let one.
4293 4302 struct NoImport;
4294 4303
4295 4304 impl Importing for NoImport {
4296 4305 fn waiting(&self) -> Option<Preflight> {
4297 4306 None
4298 4307 }
4308 + fn stage(&self) -> Stage {
4309 + Stage::Idle
4310 + }
4311 + fn sweeping(&self) -> Option<Sweep> {
4312 + None
4313 + }
4299 4314
4300 4315 fn accept(&self, _again: bool) {}
4301 4316 fn cancel(&self) {}
4317 + fn open_folder(&self) {}
4318 + fn open_quickly(&self) {}
4319 + fn open_files(&self) {}
4320 + fn change_source(&self) {}
4321 + fn decide(&self, _decision: Decision, _value: &str) {}
4322 + fn begin(&self) {}
4323 + fn stop(&self) {}
4324 + fn retry(&self) {}
4325 + fn dismiss(&self) {}
4326 + fn tag_folder(&self, _at: usize, _typed: &str) {}
4327 + fn tag_every_folder(&self, _typed: &str) {}
4328 + fn apply_folder_tags(&self) {}
4329 + fn skip_folder_tags(&self) {}
4330 + fn measure(&self, _measure: Measure, _wanted: bool) {}
4331 + fn analyse(&self) {}
4332 + fn back_to_tagging(&self) {}
4333 + fn skip_analysis(&self) {}
4334 + fn stop_analysis(&self) {}
4335 + fn retry_analysis(&self) {}
4336 + fn order(&self, _order: Order) {}
4337 + fn read(&self, _at: usize) {}
4338 + fn judge(&self, _at: usize, _tag: &str, _accepted: bool) {}
4339 + fn judge_all(&self, _accepted: bool) {}
4340 + fn apply_suggestions(&self) {}
4341 + fn discard_suggestions(&self) {}
4342 + fn keep_failed(&self) {}
4343 + fn purge_failed(&self, _at: Option<usize>) {}
4344 + fn stop_sweep(&self) {}
4302 4345 }
4303 4346
4304 4347 /// Every file is where it should be.
@@ -4579,12 +4622,31 @@
4579 4622 router().handle(&state, request)
4580 4623 }
4581 4624
4582 - /// An import waiting, recording what was answered.
4625 + /// Importing in memory, recording what was asked of it.
4626 + ///
4627 + /// One fixture for the preflight and the flow, because they are one capability:
4628 + /// a test names the stage it is about and leaves the other half at rest. The
4629 + /// stage is fixed per test rather than advancing, which is [`FakeExport`]'s
4630 + /// honest shape for the same reason — what moves a stage is the app applying an
4631 + /// intent, and these are tests of the description.
4583 4632 struct FakeImport {
4584 4633 waiting: Option<Preflight>,
4634 + stage: Stage,
4635 + sweep: Option<Sweep>,
4585 4636 answered: RefCell<Vec<String>>,
4586 4637 }
4587 4638
4639 + impl Default for FakeImport {
4640 + fn default() -> Self {
4641 + Self {
4642 + waiting: None,
4643 + stage: Stage::Idle,
4644 + sweep: None,
4645 + answered: RefCell::new(Vec::new()),
4646 + }
4647 + }
4648 + }
4649 +
4588 4650 impl FakeImport {
4589 4651 fn waiting() -> Self {
4590 4652 Self {
@@ -4593,13 +4655,31 @@
4593 4655 files: 412,
4594 4656 size: "3.1 GB".to_owned(),
4595 4657 }),
4596 - answered: RefCell::new(Vec::new()),
4658 + ..Self::default()
4659 + }
4660 + }
4661 +
4662 + fn at(stage: Stage) -> Self {
4663 + Self {
4664 + stage,
4665 + ..Self::default()
4666 + }
4667 + }
4668 +
4669 + fn sweeping(sweep: Sweep) -> Self {
4670 + Self {
4671 + sweep: Some(sweep),
4672 + ..Self::default()
4597 4673 }
4598 4674 }
4599 4675
4600 4676 fn answered(&self) -> Vec<String> {
4601 4677 self.answered.borrow().clone()
4602 4678 }
4679 +
4680 + fn say(&self, said: impl Into<String>) {
4681 + self.answered.borrow_mut().push(said.into());
4682 + }
4603 4683 }
4604 4684
4605 4685 impl Importing for FakeImport {
@@ -4607,14 +4687,135 @@
4607 4687 self.waiting.clone()
4608 4688 }
4609 4689
4690 + fn stage(&self) -> Stage {
4691 + self.stage.clone()
4692 + }
4693 +
4694 + fn sweeping(&self) -> Option<Sweep> {
4695 + self.sweep.clone()
4696 + }
4697 +
4610 4698 fn accept(&self, again: bool) {
4611 - self.answered
4612 - .borrow_mut()
4613 - .push(format!("accept, ask again: {again}"));
4699 + self.say(format!("accept, ask again: {again}"));
4614 4700 }
4615 4701
4616 4702 fn cancel(&self) {
4617 - self.answered.borrow_mut().push("cancel".to_owned());
4703 + self.say("cancel");
4704 + }
4705 +
4706 + fn open_folder(&self) {
4707 + self.say("open:folder");
4708 + }
4709 +
4710 + fn open_quickly(&self) {
4711 + self.say("open:quick");
4712 + }
4713 +
4714 + fn open_files(&self) {
4715 + self.say("open:files");
4716 + }
4717 +
4718 + fn change_source(&self) {
4719 + self.say("open:source");
4720 + }
4721 +
4722 + fn decide(&self, decision: Decision, value: &str) {
4723 + self.say(format!("set:{}={value}", decision.as_str()));
4724 + }
4725 +
4726 + fn begin(&self) {
4727 + self.say("begin");
4728 + }
4729 +
4730 + fn stop(&self) {
4731 + self.say("stop");
4732 + }
4733 +
4734 + fn retry(&self) {
4735 + self.say("retry");
4736 + }
4737 +
4738 + fn dismiss(&self) {
4739 + self.say("dismiss");
4740 + }
4741 +
4742 + fn tag_folder(&self, at: usize, typed: &str) {
4743 + self.say(format!("tag:{at}={typed}"));
4744 + }
4745 +
4746 + fn tag_every_folder(&self, typed: &str) {
4747 + self.say(format!("tag:all={typed}"));
4748 + }
4749 +
4750 + fn apply_folder_tags(&self) {
4751 + self.say("tags:apply");
4752 + }
4753 +
4754 + fn skip_folder_tags(&self) {
4755 + self.say("tags:skip");
4756 + }
4757 +
4758 + fn measure(&self, measure: Measure, wanted: bool) {
4759 + self.say(format!("measure:{}={wanted}", measure.as_str()));
4760 + }
4761 +
4762 + fn analyse(&self) {
4763 + self.say("analyse");
4764 + }
4765 +
4766 + fn back_to_tagging(&self) {
4767 + self.say("analyse:back");
4768 + }
4769 +
4770 + fn skip_analysis(&self) {
4771 + self.say("analyse:skip");
4772 + }
4773 +
4774 + fn stop_analysis(&self) {
4775 + self.say("analyse:stop");
4776 + }
4777 +
4778 + fn retry_analysis(&self) {
4779 + self.say("analyse:retry");
4780 + }
4781 +
4782 + fn order(&self, order: Order) {
4783 + self.say(format!("order:{}", order.as_str()));
4784 + }
4785 +
4786 + fn read(&self, at: usize) {
4787 + self.say(format!("read:{at}"));
4788 + }
4789 +
4790 + fn judge(&self, at: usize, tag: &str, accepted: bool) {
4791 + self.say(format!("judge:{at}:{tag}={accepted}"));
4792 + }
4793 +
4794 + fn judge_all(&self, accepted: bool) {
4795 + self.say(format!("judge:all={accepted}"));
4796 + }
4797 +
4798 + fn apply_suggestions(&self) {
4799 + self.say("review:apply");
4800 + }
4801 +
4802 + fn discard_suggestions(&self) {
4803 + self.say("review:discard");
4804 + }
4805 +
4806 + fn keep_failed(&self) {
4807 + self.say("failed:keep");
4808 + }
4809 +
4810 + fn purge_failed(&self, at: Option<usize>) {
4811 + self.say(match at {
4812 + Some(at) => format!("failed:purge:{at}"),
4813 + None => "failed:purge:all".to_owned(),
4814 + });
4815 + }
4816 +
4817 + fn stop_sweep(&self) {
4818 + self.say("sweep:stop");
4618 4819 }
4619 4820 }
4620 4821
@@ -5329,3 +5530,1091 @@
5329 5530 assert!(response.notice.is_some(), "{path} said nothing");
5330 5531 }
5331 5532 }
5533 +
5534 + // --- the import flow ---
5535 +
5536 + /// The screen the flow answers, at whatever stage it is at.
5537 + fn imported(import: &FakeImport) -> Screen {
5538 + screen_of(&importing(import, Request::get("/import")).unwrap()).clone()
5539 + }
5540 +
5541 + /// Every node on a screen, descending into regions.
5542 + ///
5543 + /// [`nodes`] walks the screen's own slots and stops. The flow's tagging stage
5544 + /// puts a field inside a group per folder and its review stage puts two panes
5545 + /// inside a split, so a test of either has to go down.
5546 + fn deep_nodes(screen: &Screen) -> Vec<Node> {
5547 + fn walk(body: &[quasi_router::Ranked], into: &mut Vec<Node>) {
5548 + for placed in body {
5549 + into.push(placed.node.clone());
5550 + if let Node::Region(slot) = &placed.node {
5551 + walk(&slot.body, into);
5552 + }
5553 + }
5554 + }
5555 +
5556 + let mut found = Vec::new();
5557 + for slot in &screen.slots {
5558 + walk(&slot.body, &mut found);
5559 + }
5560 + found
5561 + }
5562 +
5563 + /// Every act on a screen, by label, descending into regions.
5564 + fn deep_acts(screen: &Screen) -> Vec<quasi_router::Act> {
5565 + deep_nodes(screen)
5566 + .into_iter()
5567 + .filter_map(|node| match node {
5568 + Node::Act(act) => Some(act),
5569 + _ => None,
5570 + })
5571 + .collect()
5572 + }
5573 +
5574 + /// The labels of every act on a screen, descending into regions.
5575 + fn deep_labels(screen: &Screen) -> Vec<String> {
5576 + deep_acts(screen).into_iter().map(|act| act.label).collect()
5577 + }
5578 +
5579 + /// Every field on a screen, by name, descending into regions.
5580 + fn deep_fields(screen: &Screen) -> Vec<quasi_router::Field> {
5581 + deep_nodes(screen)
5582 + .into_iter()
5583 + .flat_map(|node| match node {
5584 + Node::Field(field) => vec![*field],
5585 + Node::Form { fields, .. } => fields,
5586 + _ => Vec::new(),
5587 + })
5588 + .collect()
5589 + }
5590 +
5591 + /// What one part of a row says.
5592 + fn said_in(row: &quasi_router::Row, part: quasi_router::layout::RowPart) -> String {
5593 + row.role(part)
5594 + .filter_map(|node| match node {
5595 + Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
5596 + _ => None,
5597 + })
5598 + .collect::<Vec<_>>()
5599 + .join(" ")
5600 + }
5601 +
5602 + /// Every row of every list on a screen, descending into regions.
5603 + fn deep_rows(screen: &Screen) -> Vec<quasi_router::Row> {
5604 + deep_nodes(screen)
5605 + .into_iter()
5606 + .flat_map(|node| match node {
5607 + Node::List { rows, .. } => rows,
5608 + _ => Vec::new(),
5609 + })
5610 + .collect()
5611 + }
5612 +
5613 + /// Everything a screen says, descending into regions.
5614 + fn deep_said(screen: &Screen) -> String {
5615 + deep_nodes(screen)
5616 + .iter()
5617 + .filter_map(|node| match node {
5618 + Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => {
5619 + Some(text.clone())
5620 + }
5621 + Node::StandIn { message, .. } => Some(message.clone()),
5622 + _ => None,
5623 + })
5624 + .collect::<Vec<_>>()
5625 + .join(" | ")
5626 + }
5627 +
5628 + /// An import being configured, with whatever answers a test wants.
5629 + fn configuring(strategy: Strategy, vault_name: &str, vaults: &[&str]) -> Stage {
5630 + Stage::Configuring {
5631 + source: "/home/max/Downloads/packs".to_owned(),
5632 + files: 412,
5633 + strategy,
5634 + vault_name: vault_name.to_owned(),
5635 + vaults: vaults
5636 + .iter()
5637 + .map(|name| VaultChoice {
5638 + name: (*name).to_owned(),
5639 + })
5640 + .collect(),
5641 + merging_into: 0,
5642 + }
5643 + }
5644 +
5645 + /// One reviewed sample with the suggestions a test names.
5646 + fn reviewed(name: &str, suggestions: &[(&str, f32, bool)]) -> Reviewed {
5647 + Reviewed {
5648 + name: name.to_owned(),
5649 + duration: 1.25,
5650 + sample_rate: 48_000,
5651 + peak_db: Some(-3.2),
5652 + bpm: Some(128.0),
5653 + musical_key: Some("Am".to_owned()),
5654 + suggestions: suggestions
5655 + .iter()
5656 + .map(|(tag, confidence, accepted)| Suggestion {
5657 + tag: (*tag).to_owned(),
5658 + confidence: *confidence,
5659 + reason: format!("because of {tag}"),
5660 + accepted: *accepted,
5661 + })
5662 + .collect(),
5663 + }
5664 + }
5665 +
5666 + #[test]
5667 + fn nine_stages_answer_one_address_because_none_of_them_is_a_place() {
5668 + // `export`'s rule at three times the size. A user does not navigate to
5669 + // "files are being copied"; they arrive there because they pressed Import.
5670 + let stages = [
5671 + (Stage::Idle, "Nothing is being imported"),
5672 + (configuring(Strategy::Flat, "", &[]), "Import Folder"),
5673 + (
5674 + Stage::Scanning {
5675 + found: 40,
5676 + size: Some("1.2 GB".to_owned()),
5677 + },
5678 + "Scanning for audio files",
5679 + ),
5680 + (
5681 + Stage::Copying {
5682 + done: 3,
5683 + total: 9,
5684 + current: "kick.wav".to_owned(),
5685 + size: None,
5686 + in_place: false,
5687 + failures: Vec::new(),
5688 + },
5689 + "Importing: kick.wav",
5690 + ),
5691 + (
5692 + Stage::Tagging {
5693 + folders: Vec::new(),
5694 + },
5695 + "Tag Imported Folders",
5696 + ),
5697 + (
5698 + Stage::Choosing {
5699 + samples: 12,
5700 + measures: every_measure(),
5701 + resumable: true,
5702 + },
5703 + "12 samples to analyze",
5704 + ),
5705 + (
5706 + Stage::Analysing {
5707 + done: 1,
5708 + total: 4,
5709 + current: "snare.wav".to_owned(),
5710 + failures: Vec::new(),
5711 + },
5712 + "Analysing: snare.wav",
5713 + ),
5714 + (
5715 + Stage::Reviewing {
5716 + items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])],
5717 + at: 0,
5718 + order: Order::Arrival,
5719 + },
5720 + "Review Tag Suggestions",
5721 + ),
5722 + (
5723 + Stage::Summary {
5724 + rejected: Vec::new(),
5725 + unanalysed: Vec::new(),
5726 + },
5727 + "Import Summary",
5728 + ),
5729 + ];
5730 +
5731 + for (stage, expected) in stages {
5732 + let import = FakeImport::at(stage);
5733 + let screen = imported(&import);
5734 + let said = deep_said(&screen);
5735 + assert!(said.contains(expected), "{expected} missing from: {said}");
5736 + }
5737 + }
5738 +
Lines truncated
@@ -64,9 +64,12 @@
64 64 //!
65 65 //! # What is deliberately not described
66 66 //!
67 - //! - **The Import and Export menus.** Each is a popup with several choices, and
68 - //! both belong with the import flow, which is its own remaining pass. The
69 - //! toolbar's other four right-hand controls are single acts and are here.
67 + //! - ~~**The Import and Export menus.**~~ Described as of the import flow's
68 + //! pass, which is where they said they belonged. Import is an overlay
69 + //! ([`importing::open`](super::importing)) because the shipped control is a
70 + //! popup of three choices; Export is a single act, because the shipped control
71 + //! is a single button. Both are doors into a flow rather than controls of the
72 + //! toolbar's own, which is why neither answers a screen here.
70 73 //! - **The theme selector.** Settings describes it already (`quasi/settings.rs`),
71 74 //! and a second copy in the toolbar would be the drift this layer exists to
72 75 //! end. The shipped toolbar has one because a menu was the convenient place
@@ -379,6 +382,17 @@
379 382 bar = bar.with_ranked(Node::Token(chip), panel.worth());
380 383 }
381 384
385 + // Import holds at Essential, alone among the right-hand controls, and it is
386 + // the shipped bar's own judgment: it bolds the label when the library is
387 + // empty because it is the one action that does anything then. A control that
388 + // is the only way to have any content is not one a narrow window drops.
389 + let bar = bar
390 + .with(Node::Act(Act::new("Import", Action::get("/import/open"))))
391 + .with_ranked(
392 + Node::Act(Act::new("Export", Action::post("/export/begin"))),
393 + Priority::Secondary,
394 + );
395 +
382 396 // Settings and Cloud Sync are how you reach two whole screens and have no
383 397 // other route in, so they hold at Secondary. Help drops first because it
384 398 // is the one control here that keeps working when it is not on the screen: