Skip to main content

max / audiofiles

58.7 KB · 1526 lines History Blame Raw
1 //! Importing, described: the question asked before one starts, and every stage
2 //! after it does.
3 //!
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.
102 //!
103 //! # THE FINDING: nothing in the description raises an overlay
104 //!
105 //! Every overlay described so far is one the user asked for. The bulk modals are
106 //! three addresses because *the user picked which one*; the help overlay is a
107 //! key press. This one is not asked for at all: `quick_import_folder` walks the
108 //! dropped folder, finds it is over a threshold, and the modal appears. The user
109 //! dropped a folder and the app had a question.
110 //!
111 //! A description cannot say that. `Outcome::Over` is what a *route* answers, so
112 //! an overlay exists because something was pressed; there is no way for a screen
113 //! to say "and by the way, this is waiting". The two shapes an app has for
114 //! interrupting someone — a modal it raises, and a modal you opened — are one
115 //! shape in the vocabulary, and it is the second one.
116 //!
117 //! Filed as `quasi:vocabulary:unprompted-overlay`, with **two consumers in this
118 //! one pass**: this, and the loose-files warning in [`integrity`](super::integrity),
119 //! which is raised by a vault load rather than by anything the user did.
120 //!
121 //! What the port does instead is make the waiting import an *address* and let
122 //! the screen that knows about it offer the way in. The door belongs to the
123 //! import act, which is the import flow's pass (audiofiles `f573573b`); until
124 //! then this answers by address. The alternative — having `GET /` answer
125 //! `Over` when something is pending — was tried and is wrong twice over: the
126 //! host refuses anything but a `Screen` at a window's home address, and a
127 //! screen that raises an overlay every time it is asked can never be asked
128 //! again, since `Runtime::reload` is gated on not being overlaid.
129 //!
130 //! # What the description keeps that the shipped modal spends state on
131 //!
132 //! `preflight_dont_ask` is a `bool` on `BrowserState`, set by a checkbox,
133 //! committed on confirm and reset on both exits — four writes to app state for a
134 //! question that lives as long as one modal. Here it is a field in the form,
135 //! submitted with the answer, and there is nothing to reset: what is being typed
136 //! into a described screen is the runtime's, which is the same deletion
137 //! [`bulk`](super::bulk) recorded for `BulkModal`'s eleven fields.
138 //!
139 //! # What is not described
140 //!
141 //! The threshold. `QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD` and its byte twin
142 //! decide whether there is anything to confirm, and that is the app deciding
143 //! when to ask rather than a fact about the question.
144
145 use quasi_router::layout::{FieldKind, Readiness, Selector, Tone};
146 use quasi_router::{
147 Act, Action, Choice, Field, Figure, Meter, Node, Outcome, Prose, RegionKind, Request, Response,
148 RouteError, Router, Row, Screen, Slot,
149 };
150
151 use super::{
152 Decision, Failure, FolderTags, Halted, Measure, Measures, Order, Panels, Reviewed, Stage,
153 Strategy, Sweep, VaultChoice,
154 };
155
156 /// The region the preflight answers into.
157 const BODY: &str = "import-preflight";
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
178 /// The name the "don't ask again" answer is submitted under.
179 const AGAIN: &str = "again";
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
187 /// Where an answered preflight goes.
188 const BACK: &str = "/";
189
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.
206 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
207 let router = router
208 .get("/import/preflight", screen)
209 .post("/import/preflight", accept)
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)
256 }
257
258 /// `GET /import/preflight`
259 ///
260 /// A refusal when nothing is waiting, rather than an empty modal. The address is
261 /// reachable by typing and there is no honest screen for "no import is pending".
262 fn screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
263 let waiting = state
264 .importing
265 .waiting()
266 .ok_or_else(|| RouteError::not_found("no import is waiting"))?;
267
268 let body = Slot::new(BODY, RegionKind::Pane)
269 .with(Node::page("Import folder"))
270 .with(Node::text(format!(
271 "About to import {} audio file{} (~{}) from {}",
272 waiting.files,
273 if waiting.files == 1 { "" } else { "s" },
274 waiting.size,
275 waiting.source,
276 )))
277 // The reassurance the shipped modal draws as a small muted line. It is a
278 // fact about the whole operation rather than about either answer, so it
279 // is prose and not a field's hint.
280 .with(Node::text(
281 "Files stay where they are: audiofiles only indexes them.",
282 ))
283 .with(Node::Form {
284 // The checkbox the shipped modal keeps on `BrowserState`. See the
285 // module header: here it is submitted with the answer it qualifies.
286 fields: vec![Field::new(
287 FieldKind::Checkbox,
288 AGAIN,
289 "Don't ask again for folders this size",
290 )],
291 submit: "Import".to_owned(),
292 action: Action::post("/import/preflight"),
293 })
294 .with(Node::Act(
295 Act::new("Cancel", Action::post("/import/preflight/cancel")).key("esc"),
296 ));
297
298 Ok(Response::from(Outcome::Over(
299 Screen::sidebar_content("Import folder").with(body),
300 )))
301 }
302
303 /// `POST /import/preflight`
304 fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
305 if state.importing.waiting().is_none() {
306 return Err(RouteError::not_found("no import is waiting"));
307 }
308 // A checkbox submits "on" or nothing, which is the same reading the settings
309 // screen makes of one.
310 let again = request.payload.get(AGAIN).unwrap_or_default() != "on";
311 state.importing.accept(again);
312 Ok(Response::from(Outcome::Goto(Action::get(BACK))).toast(Tone::Success, "Importing."))
313 }
314
315 /// `POST /import/preflight/cancel`
316 fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
317 state.importing.cancel();
318 Ok(Response::from(Outcome::Goto(Action::get(BACK))))
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> {
586 let at = reviewed_at(state, &request)?;
587 state.importing.read(at);
588 Ok(flow(state).into())
589 }
590
591 /// `POST /import/review/{at}/judge`
592 ///
593 /// **The tag rides in the payload rather than in the address**, which is a
594 /// deliberate departure from [`detail`](super::detail)'s
595 /// `/detail/tags/{tag}/remove`. A tag here is a `tagtree` path and may hold a
596 /// `/`, so a tag in a path segment is an address that stops matching the moment
597 /// somebody suggests a nested one. Naming it as a value is what a value is for.
598 ///
599 /// **It flips rather than sets**, and that is the renderer's shape rather than a
600 /// preference: [`Row::toggling`](quasi_router::Row::toggling) says the tick *is*
601 /// the write, and a renderer firing it sends the action with no state of its own
602 /// — the new value is not a thing the row submits. So the route reads what is
603 /// true now and answers with the other one, which is also the only reading that
604 /// survives two ticks in one frame.
605 fn judge(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
606 let at = reviewed_at(state, &request)?;
607 let tag = request.payload.get(TAG).unwrap_or_default();
608 let Stage::Reviewing { items, .. } = state.importing.stage() else {
609 return Err(RouteError::not_found("nothing is being reviewed"));
610 };
611 let held = items
612 .get(at)
613 .and_then(|item| item.suggestions.iter().find(|held| held.tag == tag))
614 .ok_or_else(|| RouteError::not_found("no such suggestion"))?;
615 state.importing.judge(at, tag, !held.accepted);
616 Ok(flow(state).into())
617 }
618
619 /// `POST /import/review/all/accept`
620 fn accept_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
621 state.importing.judge_all(true);
622 Ok(flow(state).into())
623 }
624
625 /// `POST /import/review/all/reject`
626 fn reject_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
627 state.importing.judge_all(false);
628 Ok(flow(state).into())
629 }
630
631 /// `POST /import/review/apply`
632 ///
633 /// Refused with nothing accepted, which is what the shipped button is disabled
634 /// on: applying zero tags is a control that does nothing while looking like it
635 /// commits.
636 fn apply_suggestions(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
637 let Stage::Reviewing { items, .. } = state.importing.stage() else {
638 return Err(RouteError::not_found("nothing is being reviewed"));
639 };
640 if accepted_of(&items) == 0 {
641 return Err(RouteError::not_found("no suggestions have been accepted"));
642 }
643 state.importing.apply_suggestions();
644 Ok(flow(state).into())
645 }
646
647 /// `POST /import/review/discard`
648 fn discard_suggestions(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
649 state.importing.discard_suggestions();
650 Ok(flow(state).into())
651 }
652
653 /// `POST /import/summary/keep`
654 fn keep_failed(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
655 state.importing.keep_failed();
656 Ok(flow(state).into())
657 }
658
659 /// `POST /import/summary/purge`
660 fn purge_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
661 state.importing.purge_failed(None);
662 Ok(flow(state).into())
663 }
664
665 /// `POST /import/summary/{at}/purge`
666 fn purge_one(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
667 let at: usize = request
668 .captures
669 .require("at")?
670 .parse()
671 .map_err(|_| RouteError::not_found("no such file"))?;
672 let Stage::Summary { unanalysed, .. } = state.importing.stage() else {
673 return Err(RouteError::not_found("there is no summary"));
674 };
675 if at >= unanalysed.len() {
676 return Err(RouteError::not_found("no such file"));
677 }
678 state.importing.purge_failed(Some(at));
679 Ok(flow(state).into())
680 }
681
682 /// `GET /cleanup`
683 ///
684 /// A refusal when nothing is being swept, for the preflight's reason: the
685 /// address is reachable by typing and there is no honest screen for "no sweep is
686 /// running".
687 fn sweeping(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
688 let sweep = state
689 .importing
690 .sweeping()
691 .ok_or_else(|| RouteError::not_found("nothing is being cleaned up"))?;
692 Ok(Screen::sidebar_content("Cleaning up")
693 .with(sweep_body(&sweep))
694 .into())
695 }
696
697 /// `POST /cleanup/stop`
698 fn stop_sweep(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
699 state.importing.stop_sweep();
700 Ok(Screen::sidebar_content("Cleaning up")
701 .with(match state.importing.sweeping() {
702 Some(sweep) => sweep_body(&sweep),
703 None => {
704 Slot::new(SWEEP, RegionKind::Pane).with(Node::empty("Nothing is being cleaned up."))
705 }
706 })
707 .into())
708 }
709
710 // --- the screens ---
711
712 /// Whichever of the nine screens the flow is on.
713 fn flow(state: &Panels<'_>) -> Screen {
714 let body = match state.importing.stage() {
715 Stage::Idle => idle(),
716 Stage::Configuring {
717 source,
718 files,
719 strategy,
720 vault_name,
721 vaults,
722 merging_into,
723 } => configuring(&source, files, strategy, &vault_name, &vaults, merging_into),
724 Stage::Scanning { found, size } => scanning(found, size.as_deref()),
725 Stage::Copying {
726 done,
727 total,
728 current,
729 size,
730 in_place,
731 failures,
732 } => copying(done, total, &current, size.as_deref(), in_place, &failures),
733 Stage::Tagging { folders } => tagging(&folders),
734 Stage::Choosing {
735 samples,
736 measures,
737 resumable,
738 } => choosing(samples, measures, resumable),
739 Stage::Analysing {
740 done,
741 total,
742 current,
743 failures,
744 } => analysing(done, total, &current, &failures),
745 Stage::Reviewing { items, at, order } => reviewing(&items, at, order),
746 Stage::Summary {
747 rejected,
748 unanalysed,
749 } => summary(&rejected, &unanalysed),
750 Stage::Stopped { what, done, total } => stopped(what, done, total),
751 };
752 Screen::sidebar_content("Import").with(body)
753 }
754
755 /// Nothing is being imported.
756 ///
757 /// A way in rather than a bare sentence, which is where this screen differs from
758 /// [`export`](super::export)'s idle: an export is entered by selecting samples in
759 /// a different screen and there is nothing honest to offer here, while an import
760 /// is entered by choosing a folder and that is a control.
761 fn idle() -> Slot {
762 Slot::new(FLOW, RegionKind::Pane).with(
763 Node::empty("Nothing is being imported.")
764 .offering(Act::new("Import...", Action::get("/import/open"))),
765 )
766 }
767
768 /// Where the files are going, before any of them go.
769 fn configuring(
770 source: &str,
771 files: usize,
772 strategy: Strategy,
773 vault_name: &str,
774 vaults: &[VaultChoice],
775 merging_into: usize,
776 ) -> Slot {
777 let mut body = Slot::new(FLOW, RegionKind::Pane)
778 .with(rail(0))
779 .with(Node::page("Import Folder"))
780 .with(Node::text(format!("Source: {source}")))
781 .with(Node::Act(Act::new(
782 "Change...",
783 Action::post("/import/source"),
784 )))
785 .with(Node::text(format!(
786 "{files} audio file{} found",
787 if files == 1 { "" } else { "s" }
788 )))
789 .with(Node::text(format!(
790 "Supported: {}. Duplicates will be skipped automatically.",
791 audiofiles_core::util::AUDIO_EXTENSIONS.join(", ")
792 )))
793 .with(strategy_field(strategy, vaults));
794
795 // The follow-up the chosen strategy opens, and only that one. The shipped
796 // screen does the same and the reason is the settings screen's: a control
797 // that cannot be used is worse than one that is not there.
798 match strategy {
799 Strategy::NewVault => {
800 let mut field = Field::new(FieldKind::Text, Decision::VaultName.as_str(), "Vault name")
801 .required()
802 .hint("e.g. Drum Kits")
803 .value(vault_name)
804 .changes(writes(Decision::VaultName));
805 // The state the shipped screen used to leave unexplained: Import
806 // disabled itself and said why only to a pointer that hovered it.
807 if vault_name.trim().is_empty() {
808 field = field.error("Enter a name for the new vault.");
809 }
810 body = body.with(Node::Field(Box::new(field)));
811 }
812 Strategy::Merge if vaults.is_empty() => {
813 // Reachable only by arriving here with the strategy already set,
814 // since the choice itself is refused above. Said as a banner rather
815 // than as an error on a picker that has nothing to pick from.
816 body = body.with(Node::banner(
817 Tone::Danger,
818 "No existing vaults to merge into.",
819 ));
820 }
821 Strategy::Merge => {
822 let options: Vec<Choice> = vaults
823 .iter()
824 .enumerate()
825 .map(|(at, vault)| Choice::new(at.to_string(), vault.name.clone()))
826 .collect();
827 let mut field = Field::select(Decision::MergeVault.as_str(), "Merge into", options)
828 .changes(writes(Decision::MergeVault));
829 field.value = Some(merging_into.to_string());
830 body = body.with(Node::Field(Box::new(field)));
831 }
832 Strategy::Flat => {}
833 }
834
835 // The one-way edge, said before the control that crosses it. Configure to
836 // Importing is the only transition in this flow that cannot be walked back:
837 // cancelling mid-copy keeps what has landed rather than rolling it back, so
838 // Import is a commit and the sentence is what stops it reading as a preview.
839 body = body.with(Node::banner(
840 Tone::Warning,
841 "Once started, you can cancel mid-import but copies already made will stay in the library.",
842 ));
843
844 let mut go = Act::new("Import", Action::post("/import/start"));
845 if let Some(reason) = unready(strategy, vault_name, vaults) {
846 // The reason is on the field already, which is why this one is only
847 // disabled. See the module header's second finding for the three
848 // controls where it is not.
849 let _ = reason;
850 go = go.disabled();
851 }
852
853 body.with(Node::Act(go)).with(Node::Act(Act::new(
854 "Cancel",
855 Action::post("/import/dismiss"),
856 )))
857 }
858
859 /// Where the files land, as three answers rather than a widget.
860 ///
861 /// A radio and not a dropdown, and the shipped screen's own comment is the
862 /// argument: this decides where every imported file goes and cannot be revised
863 /// without re-importing, so all three answers have to be readable without
864 /// opening anything.
865 fn strategy_field(strategy: Strategy, vaults: &[VaultChoice]) -> Node {
866 let merge = Choice::new(Strategy::Merge.as_str(), "Merge into existing vault");
867 // `Choice::unless` rather than an error under the picker: the option cannot
868 // be taken and it says why on itself, which is the member the module
869 // header's second finding wishes `Act` had.
870 let merge = if vaults.is_empty() {
871 merge.unless("No existing vaults to merge into.")
872 } else {
873 merge
874 };
875
876 let options = vec![
877 Choice::new(
878 Strategy::Flat.as_str(),
879 "Flat (all files in current directory)",
880 ),
881 Choice::new(
882 Strategy::NewVault.as_str(),
883 "New vault (preserve directory structure)",
884 ),
885 merge,
886 ];
887
888 Node::Field(Box::new(
889 Field::radio(Decision::Strategy.as_str(), "Import strategy", options)
890 .value(strategy.as_str())
891 .changes(writes(Decision::Strategy)),
892 ))
893 }
894
895 /// Walking the folder, before there is a count.
896 fn scanning(found: usize, size: Option<&str>) -> Slot {
897 let said = match (found, size) {
898 (0, _) => "Scanning for audio files...".to_owned(),
899 (found, Some(size)) => format!("Scanning for audio files... {found} found ({size})"),
900 (found, None) => format!("Scanning for audio files... {found} found"),
901 };
902
903 Slot::new(FLOW, RegionKind::Pane)
904 .with(rail(0))
905 .with(Node::page("Importing Folder..."))
906 .with(Node::StandIn {
907 state: Readiness::Pending,
908 message: said,
909 act: None,
910 })
911 // The reason the control below is dead. `Act::disabled` cannot carry it,
912 // so it is a line of its own: see the module header's second finding.
913 .with(Node::text(
914 "Cancel becomes available once the scan completes.",
915 ))
916 .with(Node::Act(
917 Act::new("Cancel", Action::post("/import/stop")).disabled(),
918 ))
919 }
920
921 /// Files being copied in.
922 fn copying(
923 done: usize,
924 total: usize,
925 current: &str,
926 size: Option<&str>,
927 in_place: bool,
928 failures: &[Failure],
929 ) -> Slot {
930 let mut body = Slot::new(FLOW, RegionKind::Pane)
931 .with(rail(0))
932 .with(Node::page("Importing Folder..."));
933
934 if let Some(size) = size {
935 // Which of the two this says is the whole point of the line: an import
936 // that references files where they sit costs no disk, and one that
937 // copies them costs this much.
938 body = body.with(Node::banner(
939 if in_place { Tone::Warning } else { Tone::Info },
940 if in_place {
941 format!("{total} files, {size} total (referenced in place, no copies)")
942 } else {
943 format!("{total} files, ~{size} will be duplicated into vault")
944 },
945 ));
946 }
947
948 body = body
949 .with(Node::Meter(
950 Meter::new(clamp(done), clamp(total)).label("files"),
951 ))
952 .with(Node::Act(Act::new("Cancel", Action::post("/import/stop"))));
953
954 if !current.is_empty() {
955 body = body.with(Node::text(format!("Importing: {current}")));
956 }
957
958 failed(body, failures, "/import/retry")
959 }
960
961 /// Naming what came in.
962 fn tagging(folders: &[FolderTags]) -> Slot {
963 let samples: usize = folders.iter().map(|folder| folder.samples).sum();
964
965 let mut body = Slot::new(FLOW, RegionKind::Pane)
966 .with(rail(1))
967 .with(Node::page("Tag Imported Folders"))
968 .with(Node::text(format!(
969 "{} folders \u{b7} {samples} samples",
970 folders.len()
971 )))
972 .with(Node::text(
973 "Assign tags to imported folders. Comma-separated. \
974 Applied to all samples within each folder.",
975 ))
976 // Broadcasting to every folder is a form rather than a field, because it
977 // is submitted deliberately: a `changes` on it would copy a half-typed
978 // tag string into every input on the way to the whole one.
979 .with(Node::Form {
980 fields: vec![
981 Field::new(FieldKind::Text, TAGS, "Apply to all").hint("e.g. one-shots, kick"),
982 ],
983 submit: "Apply to all".to_owned(),
984 action: Action::post("/import/folders/all"),
985 });
986
987 for (at, folder) in folders.iter().enumerate() {
988 let mut group = Slot::new(format!("import-folder-{at}"), RegionKind::Group)
989 .with(Node::section(format!(
990 "{} ({} samples)",
991 folder.name, folder.samples
992 )))
993 .with(Node::Field(Box::new(
994 Field::new(FieldKind::Text, TAGS, "Tags")
995 .value(&folder.typed)
996 .changes(Action::post(format!("/import/folders/{at}/tags"))),
997 )));
998 if !folder.invalid.is_empty() {
999 group = group.with(Node::banner(
1000 Tone::Danger,
1001 format!("Invalid: {}", folder.invalid.join(", ")),
1002 ));
1003 }
1004 body = body.with(Node::Region(group));
1005 }
1006
1007 let nothing_typed = folders.iter().all(|folder| folder.typed.trim().is_empty());
1008 let mut apply = Act::new("Apply Tags", Action::post("/import/folders/apply"));
1009 if nothing_typed {
1010 body = body.with(Node::text("Add at least one tag, or use Skip."));
1011 apply = apply.disabled();
1012 }
1013
1014 body.with(Node::Act(apply)).with(Node::Act(Act::new(
1015 "Skip",
1016 Action::post("/import/folders/skip"),
1017 )))
1018 }
1019
1020 /// What the analysis will measure.
1021 fn choosing(samples: usize, measures: Measures, resumable: bool) -> Slot {
1022 let mut body = Slot::new(FLOW, RegionKind::Pane)
1023 .with(rail(2))
1024 .with(Node::page("Configure Analysis"))
1025 .with(Node::text(format!("{samples} samples to analyze")));
1026
1027 for measure in Measure::ALL {
1028 body = body.with(Node::Field(Box::new(
1029 Field::new(FieldKind::Checkbox, measure.as_str(), measure.label())
1030 .value(if measure.read(&measures) { "on" } else { "" })
1031 .changes(Action::post(format!(
1032 "/import/measure/{}",
1033 measure.as_str()
1034 ))),
1035 )));
1036 }
1037
1038 let mut back = Act::new("Back", Action::post("/import/analyse/back"));
1039 if !resumable {
1040 body = body.with(Node::text(
1041 "There is no tagging step behind this run to go back to.",
1042 ));
1043 back = back.disabled();
1044 }
1045
1046 body.with(Node::Act(back))
1047 .with(Node::Act(Act::new(
1048 "Run Analysis",
1049 Action::post("/import/analyse"),
1050 )))
1051 .with(Node::Act(Act::new(
1052 "Skip analysis",
1053 Action::post("/import/analyse/skip"),
1054 )))
1055 }
1056
1057 /// Samples being analysed.
1058 fn analysing(done: usize, total: usize, current: &str, failures: &[Failure]) -> Slot {
1059 let mut body = Slot::new(FLOW, RegionKind::Pane)
1060 .with(rail(2))
1061 .with(Node::page("Analysing Samples..."))
1062 .with(Node::Meter(
1063 Meter::new(clamp(done), clamp(total)).label("samples"),
1064 ))
1065 .with(Node::Act(Act::new(
1066 "Cancel",
1067 Action::post("/import/analyse/stop"),
1068 )));
1069
1070 if !current.is_empty() {
1071 body = body.with(Node::text(format!("Analysing: {current}")));
1072 }
1073
1074 failed(body, failures, "/import/analyse/retry")
1075 }
1076
1077 /// What has gone wrong so far, and the way to start over.
1078 ///
1079 /// One list for both error kinds, which is what the shipped `draw_error_log`
1080 /// does: a running screen reports how much is going wrong rather than at which
1081 /// stage it went. The shipped Hide/Show toggle is not described — whether a list
1082 /// is folded away is the reader's, and a described screen that carried it would
1083 /// be holding view state the runtime already holds.
1084 fn failed(body: Slot, failures: &[Failure], retry: &str) -> Slot {
1085 if failures.is_empty() {
1086 return body;
1087 }
1088
1089 body.with(Node::banner(
1090 Tone::Danger,
1091 format!(
1092 "{} error{}",
1093 failures.len(),
1094 if failures.len() == 1 { "" } else { "s" }
1095 ),
1096 ))
1097 .with(Node::list(failures.iter().map(|failure| {
1098 Row::new(failure.name.clone()).secondary(Prose::Text(failure.error.clone()))
1099 })))
1100 .with(Node::Act(Act::new("Retry", Action::post(retry.to_owned()))))
1101 }
1102
1103 /// Reading what the analysis suggested.
1104 ///
1105 /// The one stage with two panes, and the region says so: [`RegionKind::Split`]
1106 /// is "two panes side by side, the left choosing what the right shows", which is
1107 /// what the shipped side panel and central pane are. The screen's own
1108 /// arrangement stays `sidebar_content` like every other stage — the split is
1109 /// inside this flow's one region rather than a different shape of screen,
1110 /// because the flow is one address and the arrangement is the window's.
1111 fn reviewing(items: &[Reviewed], at: usize, order: Order) -> Slot {
1112 let total: usize = items.iter().map(|item| item.suggestions.len()).sum();
1113 let accepted = accepted_of(items);
1114
1115 let mut head = Slot::new("review-head", RegionKind::Group)
1116 .with(Node::page("Review Tag Suggestions"))
1117 .with(Node::text(format!(
1118 "{} samples, {total} suggestions ({accepted} accepted)",
1119 items.len()
1120 )))
1121 .with(Node::Act(Act::new(
1122 "Accept All",
1123 Action::post("/import/review/all/accept"),
1124 )))
1125 .with(Node::Act(Act::new(
1126 "Reject All",
1127 Action::post("/import/review/all/reject"),
1128 )));
1129
1130 if let Some(stats) = batch(items) {
1131 head = head.with(stats);
1132 }
1133
1134 let split = Slot::new("review-split", RegionKind::Split)
1135 .with(Node::Region(listing(items, at, order)))
1136 .with(Node::Region(reading(items, at)));
1137
1138 let mut apply = Act::new(
1139 format!(
1140 "Apply {accepted} Tag{}",
1141 if accepted == 1 { "" } else { "s" }
1142 ),
1143 Action::post("/import/review/apply"),
1144 );
1145 if accepted == 0 {
1146 apply = apply.disabled();
1147 }
1148
1149 let mut body = Slot::new(FLOW, RegionKind::Pane)
1150 .with(rail(3))
1151 .with(Node::Region(head))
1152 .with(Node::Region(split));
1153
1154 if accepted == 0 {
1155 body = body.with(Node::text(
1156 "Accept at least one suggestion, or use Cancel to discard.",
1157 ));
1158 }
1159
1160 body.with(Node::Act(apply)).with(Node::Act(Act::new(
1161 "Cancel",
1162 Action::post("/import/review/discard"),
1163 )))
1164 }
1165
1166 /// What the batch looks like as a whole.
1167 ///
1168 /// Describable because it is arithmetic over results the screen already carries:
1169 /// the tempo range and the three most common keys, which is what lets a reader
1170 /// see whether the analysis looks sane before committing any of it.
1171 fn batch(items: &[Reviewed]) -> Option<Node> {
1172 let mut figures = Vec::new();
1173
1174 let tempos: Vec<f64> = items.iter().filter_map(|item| item.bpm).collect();
1175 if let (Some(low), Some(high)) = (
1176 tempos.iter().copied().reduce(f64::min),
1177 tempos.iter().copied().reduce(f64::max),
1178 ) {
1179 let said = if low < high {
1180 format!("{low:.0} - {high:.0}")
1181 } else {
1182 format!("{low:.0}")
1183 };
1184 figures.push((Figure::new(said, "BPM"), None));
1185 }
1186
1187 // A `BTreeMap` so the tie-break is by name rather than by whichever the hash
1188 // happened to put first, which is the shipped screen's ordering and the only
1189 // one that answers the same way twice.
1190 let mut keys: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
1191 for item in items {
1192 if let Some(key) = item.musical_key.as_deref() {
1193 *keys.entry(key).or_default() += 1;
1194 }
1195 }
1196 for (key, count) in keys.into_iter().take(3) {
1197 figures.push((Figure::new(count.to_string(), key.to_owned()), None));
1198 }
1199
1200 (!figures.is_empty()).then_some(Node::Stats { figures })
1201 }
1202
1203 /// Every sample with something to say about it.
1204 fn listing(items: &[Reviewed], at: usize, order: Order) -> Slot {
1205 let mut list = Slot::new(REVIEW_LIST, RegionKind::Pane).with(Node::Select {
1206 kind: Selector::Segmented,
1207 options: Order::ALL
1208 .into_iter()
1209 .map(|order| (Choice::new(order.as_str(), order.label()), None))
1210 .collect(),
1211 chosen: Some(order.as_str().to_owned()),
1212 action: Some(Action::post("/import/review/order")),
1213 });
1214
1215 if items.is_empty() {
1216 return list.with(Node::empty("Nothing was analysed."));
1217 }
1218
1219 // Ordered here, and it is the second thing this flow sorts for the reason
1220 // the first was: `sorted_indices` builds a display order the shipped screen
1221 // reads through so `current_idx` keeps pointing at the underlying item.
1222 // Rows carry their own index, so the order is presentation and the address
1223 // is not.
1224 let mut order_of: Vec<usize> = (0..items.len()).collect();
1225 match order {
1226 Order::Arrival => {}
1227 Order::Name => order_of.sort_by_key(|&at| items[at].name.to_lowercase()),
1228 Order::Suggestions => {
1229 order_of.sort_by_key(|&at| std::cmp::Reverse(items[at].suggestions.len()));
1230 }
1231 Order::Accepted => order_of.sort_by_key(|&at| std::cmp::Reverse(accepted_in(&items[at]))),
1232 }
1233
1234 list = list.with(Node::list(order_of.into_iter().map(|held| {
1235 let item = &items[held];
1236 let mut row = Row::new(item.name.clone())
1237 .activate(Action::post(format!("/import/review/{held}/read")));
1238 if !item.suggestions.is_empty() {
1239 row = row.meta(format!("{}/{}", accepted_in(item), item.suggestions.len()));
1240 }
1241 row
1242 })));
1243
1244 let _ = at;
1245 list
1246 }
1247
1248 /// The one being read, and what is proposed for it.
1249 fn reading(items: &[Reviewed], at: usize) -> Slot {
1250 let pane = Slot::new(REVIEW_ITEM, RegionKind::Pane);
1251 let Some(item) = items.get(at) else {
1252 return pane.with(Node::empty("Choose a sample to review."));
1253 };
1254
1255 let mut pane = pane
1256 .with(Node::section(item.name.clone()))
1257 .with(Node::text(format!("({} of {})", at + 1, items.len())))
1258 .with(Node::text(facts(item)));
1259
1260 if item.suggestions.is_empty() {
1261 return pane.with(Node::empty("Nothing was suggested for this sample."));
1262 }
1263
1264 pane = pane.with(Node::list(item.suggestions.iter().map(|held| {
1265 Row::new(held.tag.clone())
1266 .secondary(Prose::Text(held.reason.clone()))
1267 // The confidence as a trailing fact rather than as a colour. The
1268 // shipped row bands it green/amber/muted at 80 and 60 per cent,
1269 // which is a renderer reading a number the description carries: what
1270 // is described is how sure the analysis is.
1271 .meta(format!("{:.0}%", held.confidence * 100.0))
1272 .toggling(
1273 held.accepted,
1274 Action::post(format!("/import/review/{at}/judge")).carrying(TAG, held.tag.clone()),
1275 )
1276 })));
1277
1278 pane
1279 }
1280
1281 /// What analysis found about one sample, as one line.
1282 ///
1283 /// Joined rather than five nodes, for the reason [`export`](super::export)'s
1284 /// device profile is: the shipped screen's two rows of small labels are a layout
1285 /// choice, and what is described is one statement about one sample.
1286 fn facts(item: &Reviewed) -> String {
1287 let mut said = vec![
1288 format!("{:.2}s", item.duration),
1289 format!("{}Hz", item.sample_rate),
1290 ];
1291 if let Some(peak) = item.peak_db {
1292 said.push(format!("Peak: {peak:.1}dB"));
1293 }
1294 if let Some(bpm) = item.bpm {
1295 said.push(format!("{bpm:.1} BPM"));
1296 }
1297 if let Some(key) = item.musical_key.as_deref() {
1298 said.push(key.to_owned());
1299 }
1300 said.join(" \u{b7} ")
1301 }
1302
1303 /// What failed, once the run is over.
1304 fn summary(rejected: &[Failure], unanalysed: &[Failure]) -> Slot {
1305 let mut body = Slot::new(FLOW, RegionKind::Pane).with(Node::page("Import Summary"));
1306
1307 // The two lists are different situations and the shipped screen's copy is
1308 // what says so: one is remediable from here and one is not. Kept as two
1309 // lists rather than folded into the one the progress screens show, because
1310 // what a finished screen reports is what to do about it.
1311 if !unanalysed.is_empty() {
1312 body = body
1313 .with(Node::banner(
1314 Tone::Danger,
1315 format!(
1316 "{} file{} failed analysis",
1317 unanalysed.len(),
1318 if unanalysed.len() == 1 { "" } else { "s" }
1319 ),
1320 ))
1321 .with(Node::text(
1322 "These files are in the library but couldn't be analysed. \
1323 You can remove them, ignore them, or re-analyse later.",
1324 ))
1325 .with(Node::list(unanalysed.iter().enumerate().map(
1326 |(at, failure)| {
1327 Row::new(failure.name.clone())
1328 .secondary(Prose::Text(failure.error.clone()))
1329 .offers(
1330 Act::new(
1331 "Remove",
1332 Action::post(format!("/import/summary/{at}/purge")),
1333 )
1334 .tone(Tone::Danger)
1335 .confirm(format!(
1336 "\"{}\" will be permanently removed from the library. Remove?",
1337 failure.name
1338 )),
1339 )
1340 },
1341 )));
1342 }
1343
1344 if !rejected.is_empty() {
1345 body = body
1346 .with(Node::banner(
1347 Tone::Danger,
1348 format!(
1349 "{} file{} failed to import",
1350 rejected.len(),
1351 if rejected.len() == 1 { "" } else { "s" }
1352 ),
1353 ))
1354 .with(Node::text(
1355 "These files weren't imported. Re-running the import \
1356 is the only way to retry \u{2014} duplicates will be skipped.",
1357 ))
1358 .with(Node::list(rejected.iter().map(|failure| {
1359 Row::new(failure.name.clone()).secondary(Prose::Text(failure.error.clone()))
1360 })));
1361 }
1362
1363 if rejected.is_empty() && unanalysed.is_empty() {
1364 body = body.with(Node::empty("Nothing failed."));
1365 }
1366
1367 body = body.with(Node::Act(Act::new(
1368 "Keep All",
1369 Action::post("/import/summary/keep"),
1370 )));
1371
1372 if unanalysed.is_empty() {
1373 return body;
1374 }
1375
1376 body.with(Node::Act(
1377 Act::new("Remove All Failed", Action::post("/import/summary/purge"))
1378 .tone(Tone::Danger)
1379 .confirm(format!(
1380 "{} samples will be permanently removed from the library. Remove?",
1381 unanalysed.len()
1382 )),
1383 ))
1384 }
1385
1386 /// Given up on partway.
1387 fn stopped(what: Halted, done: usize, total: usize) -> Slot {
1388 let (heading, noun, follow_up) = match what {
1389 Halted::Import => (
1390 "Import cancelled",
1391 "files",
1392 "Imported files remain in the library. Re-run the import to add the rest \u{2014} \
1393 duplicates will be skipped.",
1394 ),
1395 Halted::Analysis => (
1396 "Analysis cancelled",
1397 "samples",
1398 "Analysed samples keep their results. The remaining samples are unanalysed \u{2014} \
1399 run analysis again to complete them.",
1400 ),
1401 };
1402
1403 Slot::new(FLOW, RegionKind::Pane)
1404 .with(Node::page(heading))
1405 .with(Node::text(format!("Stopped at {done} of {total} {noun}.")))
1406 .with(Node::text(follow_up))
1407 .with(Node::Act(Act::new("Done", Action::post("/import/dismiss"))))
1408 }
1409
1410 /// Orphaned samples being swept up.
1411 fn sweep_body(sweep: &Sweep) -> Slot {
1412 let mut body = Slot::new(SWEEP, RegionKind::Pane).with(Node::page("Cleaning Up Samples..."));
1413
1414 // Zero is "the scan has not counted them yet" rather than an empty sweep,
1415 // and a meter of 0/0 would draw as finished. The reading `export`'s progress
1416 // screen settled.
1417 body = if sweep.total == 0 {
1418 body.with(Node::StandIn {
1419 state: Readiness::Pending,
1420 message: "Scanning for orphaned samples...".to_owned(),
1421 act: None,
1422 })
1423 } else {
1424 body.with(Node::Meter(
1425 Meter::new(clamp(sweep.done), clamp(sweep.total)).label("samples"),
1426 ))
1427 };
1428
1429 if !sweep.current.is_empty() {
1430 body = body.with(Node::text(format!("Removing: {}", sweep.current)));
1431 }
1432
1433 body.with(Node::Act(Act::new("Cancel", Action::post("/cleanup/stop"))))
1434 }
1435
1436 // --- what the screens are built out of ---
1437
1438 /// Where the flow is, as prose.
1439 ///
1440 /// The degraded stage rail. See the module header: the vocabulary says which
1441 /// child of a region is up and not who chose it, so a described rail would tell
1442 /// every renderer to offer navigation this flow does not have. The names and the
1443 /// position survive; the strip does not.
1444 fn rail(step: usize) -> Node {
1445 Node::text(format!(
1446 "Step {} of {}: {}",
1447 step + 1,
1448 STEPS.len(),
1449 STEPS[step]
1450 ))
1451 }
1452
1453 /// The address a control changing this answers calls.
1454 fn writes(decision: Decision) -> Action {
1455 Action::post(format!("/import/set/{}", decision.as_str()))
1456 }
1457
1458 /// Why the import cannot start yet, if it cannot.
1459 ///
1460 /// One function for the route's refusal and the control's disabled state, which
1461 /// is what stops the two disagreeing: a control that is enabled where the route
1462 /// refuses is a button that reports an error nobody could have avoided.
1463 fn unready(strategy: Strategy, vault_name: &str, vaults: &[VaultChoice]) -> Option<&'static str> {
1464 match strategy {
1465 Strategy::Flat => None,
1466 Strategy::NewVault => vault_name
1467 .trim()
1468 .is_empty()
1469 .then_some("enter a name for the new vault"),
1470 Strategy::Merge => vaults
1471 .is_empty()
1472 .then_some("there are no existing vaults to merge into"),
1473 }
1474 }
1475
1476 /// The folder a request names, refusing one that is not there.
1477 fn folder_at(state: &Panels<'_>, request: &Request) -> Result<usize, RouteError> {
1478 let at: usize = request
1479 .captures
1480 .require("at")?
1481 .parse()
1482 .map_err(|_| RouteError::not_found("no such folder"))?;
1483 let Stage::Tagging { folders } = state.importing.stage() else {
1484 return Err(RouteError::not_found("no folders are being tagged"));
1485 };
1486 if at >= folders.len() {
1487 return Err(RouteError::not_found("no such folder"));
1488 }
1489 Ok(at)
1490 }
1491
1492 /// The reviewed sample a request names, refusing one that is not there.
1493 fn reviewed_at(state: &Panels<'_>, request: &Request) -> Result<usize, RouteError> {
1494 let at: usize = request
1495 .captures
1496 .require("at")?
1497 .parse()
1498 .map_err(|_| RouteError::not_found("no such sample"))?;
1499 let Stage::Reviewing { items, .. } = state.importing.stage() else {
1500 return Err(RouteError::not_found("nothing is being reviewed"));
1501 };
1502 if at >= items.len() {
1503 return Err(RouteError::not_found("no such sample"));
1504 }
1505 Ok(at)
1506 }
1507
1508 /// How many suggestions are accepted across the whole review.
1509 fn accepted_of(items: &[Reviewed]) -> usize {
1510 items.iter().map(accepted_in).sum()
1511 }
1512
1513 /// How many of one sample's suggestions are accepted.
1514 fn accepted_in(item: &Reviewed) -> usize {
1515 item.suggestions.iter().filter(|held| held.accepted).count()
1516 }
1517
1518 /// A count as the meter carries one.
1519 ///
1520 /// Saturating rather than `as`, for [`export`](super::export)'s reason: a
1521 /// truncating cast on a count that came from a worker is the kind of arithmetic
1522 /// that reads as fine and is not.
1523 fn clamp(count: usize) -> u32 {
1524 u32::try_from(count).unwrap_or(u32::MAX)
1525 }
1526