//! Importing, described: the question asked before one starts, and every stage //! after it does. //! //! Two passes in one module, and the second one is why. It landed as the //! Quick-Import preflight alone, whose header said the wizard screens "are their //! own pass, and this trait is what that pass grows". They are grown here rather //! than filed beside, because the preflight and the flow are one subject asked at //! two moments: whether to import, and then how it is going. A second module //! would have made [`Importing`](super::Importing) two capabilities over one //! `import_wf`, which is the split [`Bulk`](super::Bulk) and [`Detail`](super::Detail) //! took for a reason this has not got — they narrow *differently* over one //! selection, and these do not narrow at all. //! //! # The flow: nine stages, one address //! //! [`export`](super::export)'s rule, applied rather than discovered: which screen //! is showing is a fact about the app, so `GET /import` answers whichever of the //! nine [`Stage`](super::Stage)s the flow is at, and nothing navigates between //! them. What is new is only the size — nine against five, and thirty-one routes //! against five — and the size is the app's: `ImportMode` carries all nine arms //! and `ui::import_screens` draws one screen per arm. //! //! Three of the shipped file's screens are **not** stages of this flow, and //! saying so is part of the port: //! //! - `draw_cleanup_progress` sweeps orphaned samples. Started by a bulk delete //! and by the export flow, reachable from nothing in the wizard, and drawn in //! `progress.rs` only because that file collects progress bars. Its own //! address, `/cleanup`. See [`Sweep`](super::Sweep). //! - `draw_operation_cancelled` serves three operations, and the export one is //! [`Phase::Cancelled`](super::Phase::Cancelled) already. [`Halted`](super::Halted) //! is `CancelKind` less that arm. //! - `ImportMode::ReviewLibrary` is on the same enum and is a different screen //! entirely. //! //! # THE FINDING: a stage rail is not a tab strip, and the vocabulary has one shape for both //! //! `WIZARD_STEPS` is four names — Configure, Tag folders, Analyze, Review — drawn //! on every screen of the flow with the current one marked, so "where am I / how //! much is left" never vanishes mid-flow. Four of the screens ported here draw //! it. //! //! [`Slot::showing_one`](quasi_router::Slot::showing_one) is the near miss, and //! it is near enough to be worth writing down why it was refused. It says //! *exactly one of these named children is up*, it carries the index and the //! per-child labels, and `makeover-layout`'s own note says a renderer draws //! labelled children as "a strip of the labels, the current one marked" — which //! is the rail, described. What it also says is //! [`Showing::selective`](https://docs.rs/makeover-layout): "whether the reader //! can change which child is up", true for `One`. A carousel and a tab group are //! what the member is for, and both are the reader's. **A wizard's stage is the //! app's.** The shipped code is explicit about it — "the wizard has no //! cross-step navigation post-Skip" — and a renderer told `showing_one` would //! offer a strip that navigates to a stage the flow is not at, whose region is //! empty because nothing has happened there yet. //! //! So the gap is one bit: **who chooses which child is up.** Filed as //! `quasi:vocabulary:unchosen-stage`, with four measured consumers in this one //! pass, and it degrades the way the token chips did — the description keeps the //! fact and loses the affordance, as `Step 2 of 4: Tag folders` in prose. //! //! # THE SECOND FINDING: `Act::disabled` carries no reason, and `Choice::unless` does //! //! [`Choice::unless`](quasi_router::Choice::unless) is "the same option, not //! pickable yet, **and why**". [`Act::disabled`](quasi_router::Act::disabled) is //! the same fact about a control with the why left out, so every renderer draws a //! dead button and the reader is left to work out what would wake it. //! //! This flow disables four controls and the shipped screen explains all four in //! an `on_disabled_hover_text`: Import until a new vault has a name, Cancel until //! the walk finishes, Apply Tags until something is typed, Apply N Tags until //! something is accepted. Filed as `quasi:vocabulary:disabled-reason`. Where the //! reason is already a field's [`error`](quasi_router::Field::error) it is not //! lost — that is the configure screen — and where it is not, it is an extra //! prose line, which is the degradation. //! //! # What is not describable, and it is three things //! //! | The shipped screen does | Described | Why not | //! |---|---|---| //! | invalid-tag warning per folder | yes | `tags::validate_tag` over what was typed, and pure | //! | BPM range and top keys over the batch | yes | arithmetic over the results the screen already carries | //! | **rate and ETA** | no | a rolling wall-clock buffer, which is a fact about this machine | //! | **the four native pickers** | no | `quasi:vocabulary:host-save-location`, consumers five to eight | //! | **↑/↓ walking the review list** | no | a key bound to "the next row of a list", which `Chrome` cannot say | //! //! The pickers are the sharper half. `Import folder...`, `Quick import //! folder...`, `Import files...` and `Change...` each open a native dialog and //! then act, which is the gap `integrity` filed as its fourth consumer. **Four //! more arrive here at once**, and with them a second half of the same gap that //! nothing had hit yet: a route that hands off to the host has nothing true to //! answer. [`Outcome`](quasi_router::Outcome) is `Screen`, `Fragment`, `Goto` or //! `Over`, and none of them is "nothing here changed". They answer //! `Goto(/import)` — right by the time the picker returns, and a frame early, //! which is the standing cost `Runtime::reload` corrects. //! //! # The preflight //! //! What follows is the first pass, unchanged. Its finding is unaffected by the //! flow arriving: an unprompted overlay is still unsayable, and the door the //! header promised the import act would own is [`open`](fn@open) below. //! //! # THE FINDING: nothing in the description raises an overlay //! //! Every overlay described so far is one the user asked for. The bulk modals are //! three addresses because *the user picked which one*; the help overlay is a //! key press. This one is not asked for at all: `quick_import_folder` walks the //! dropped folder, finds it is over a threshold, and the modal appears. The user //! dropped a folder and the app had a question. //! //! A description cannot say that. `Outcome::Over` is what a *route* answers, so //! an overlay exists because something was pressed; there is no way for a screen //! to say "and by the way, this is waiting". The two shapes an app has for //! interrupting someone — a modal it raises, and a modal you opened — are one //! shape in the vocabulary, and it is the second one. //! //! Filed as `quasi:vocabulary:unprompted-overlay`, with **two consumers in this //! one pass**: this, and the loose-files warning in [`integrity`](super::integrity), //! which is raised by a vault load rather than by anything the user did. //! //! What the port does instead is make the waiting import an *address* and let //! the screen that knows about it offer the way in. The door belongs to the //! import act, which is the import flow's pass (audiofiles `f573573b`); until //! then this answers by address. The alternative — having `GET /` answer //! `Over` when something is pending — was tried and is wrong twice over: the //! host refuses anything but a `Screen` at a window's home address, and a //! screen that raises an overlay every time it is asked can never be asked //! again, since `Runtime::reload` is gated on not being overlaid. //! //! # What the description keeps that the shipped modal spends state on //! //! `preflight_dont_ask` is a `bool` on `BrowserState`, set by a checkbox, //! committed on confirm and reset on both exits — four writes to app state for a //! question that lives as long as one modal. Here it is a field in the form, //! submitted with the answer, and there is nothing to reset: what is being typed //! into a described screen is the runtime's, which is the same deletion //! [`bulk`](super::bulk) recorded for `BulkModal`'s eleven fields. //! //! # What is not described //! //! The threshold. `QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD` and its byte twin //! decide whether there is anything to confirm, and that is the app deciding //! when to ask rather than a fact about the question. use quasi_router::layout::{FieldKind, Readiness, Selector, Tone}; use quasi_router::{ Act, Action, Choice, Field, Figure, Meter, Node, Outcome, Prose, RegionKind, Request, Response, RouteError, Router, Row, Screen, Slot, }; use super::{ Decision, Failure, FolderTags, Halted, Measure, Measures, Order, Panels, Reviewed, Stage, Strategy, Sweep, VaultChoice, }; /// The region the preflight answers into. const BODY: &str = "import-preflight"; /// The region the whole flow answers into. /// /// One region for nine screens, and [`export`](super::export)'s reason holds /// here with an extra edge to it: the flow does not only fail to navigate /// backwards, it has a transition it *cannot* walk back over. Configure to /// Importing is one-way — files already copied stay in the library — and the /// shipped screen says so in a line above the button. A region per stage would /// have implied a trail that does not exist. const FLOW: &str = "import-body"; /// The region the sweep answers into. const SWEEP: &str = "cleanup-body"; /// The list half of the review screen. const REVIEW_LIST: &str = "review-samples"; /// The reading half of the review screen. const REVIEW_ITEM: &str = "review-item"; /// The name the "don't ask again" answer is submitted under. const AGAIN: &str = "again"; /// The name a folder's tags are submitted under. const TAGS: &str = "tags"; /// The name a judged suggestion rides under. const TAG: &str = "tag"; /// Where an answered preflight goes. const BACK: &str = "/"; /// Where a door goes once the host has been asked. /// /// The flow, which is right by the time the picker answers and one frame early /// when it is pressed. See the module header on why there is nothing better to /// say: no [`Outcome`] means "nothing here changed". const FLOW_HOME: &str = "/import"; /// The four names the shipped wizard rails, in order. /// /// Kept as the flow's own fact rather than read off `ui::import_screens`: the /// rail is what the reader is told about the shape of the flow, and that is a /// described thing even while the way of drawing it is not. See the module /// header, `quasi:vocabulary:unchosen-stage`. const STEPS: [&str; 4] = ["Configure", "Tag folders", "Analyze", "Review"]; /// Register the preflight's routes, and the flow's. pub fn routes(router: Router>) -> Router> { let router = router .get("/import/preflight", screen) .post("/import/preflight", accept) .post("/import/preflight/cancel", cancel); let router = router .get("/import", index) .get("/import/open", open) .post("/import/open/folder", open_folder) .post("/import/open/quick", open_quickly) .post("/import/open/files", open_files) .post("/import/source", change_source) .post("/import/set/{decision}", decide) .post("/import/start", start) .post("/import/stop", stop) .post("/import/retry", retry) .post("/import/dismiss", dismiss); let router = router .post("/import/folders/all", tag_every_folder) .post("/import/folders/apply", apply_folder_tags) .post("/import/folders/skip", skip_folder_tags) .post("/import/folders/{at}/tags", tag_folder); let router = router .post("/import/measure/{measure}", measure) .post("/import/analyse", analyse) .post("/import/analyse/back", back_to_tagging) .post("/import/analyse/skip", skip_analysis) .post("/import/analyse/stop", stop_analysis) .post("/import/analyse/retry", retry_analysis); let router = router .post("/import/review/order", order) .post("/import/review/all/accept", accept_all) .post("/import/review/all/reject", reject_all) .post("/import/review/apply", apply_suggestions) .post("/import/review/discard", discard_suggestions) .post("/import/review/{at}/read", read) .post("/import/review/{at}/judge", judge); let router = router .post("/import/summary/keep", keep_failed) .post("/import/summary/purge", purge_all) .post("/import/summary/{at}/purge", purge_one); router .get("/cleanup", sweeping) .post("/cleanup/stop", stop_sweep) } /// `GET /import/preflight` /// /// A refusal when nothing is waiting, rather than an empty modal. The address is /// reachable by typing and there is no honest screen for "no import is pending". fn screen(state: &Panels<'_>, _request: Request) -> Result { let waiting = state .importing .waiting() .ok_or_else(|| RouteError::not_found("no import is waiting"))?; let body = Slot::new(BODY, RegionKind::Pane) .with(Node::page("Import folder")) .with(Node::text(format!( "About to import {} audio file{} (~{}) from {}", waiting.files, if waiting.files == 1 { "" } else { "s" }, waiting.size, waiting.source, ))) // The reassurance the shipped modal draws as a small muted line. It is a // fact about the whole operation rather than about either answer, so it // is prose and not a field's hint. .with(Node::text( "Files stay where they are: audiofiles only indexes them.", )) .with(Node::Form { // The checkbox the shipped modal keeps on `BrowserState`. See the // module header: here it is submitted with the answer it qualifies. fields: vec![Field::new( FieldKind::Checkbox, AGAIN, "Don't ask again for folders this size", )], submit: "Import".to_owned(), action: Action::post("/import/preflight"), }) .with(Node::Act( Act::new("Cancel", Action::post("/import/preflight/cancel")).key("esc"), )); Ok(Response::from(Outcome::Over( Screen::sidebar_content("Import folder").with(body), ))) } /// `POST /import/preflight` fn accept(state: &Panels<'_>, request: Request) -> Result { if state.importing.waiting().is_none() { return Err(RouteError::not_found("no import is waiting")); } // A checkbox submits "on" or nothing, which is the same reading the settings // screen makes of one. let again = request.payload.get(AGAIN).unwrap_or_default() != "on"; state.importing.accept(again); Ok(Response::from(Outcome::Goto(Action::get(BACK))).toast(Tone::Success, "Importing.")) } /// `POST /import/preflight/cancel` fn cancel(state: &Panels<'_>, _request: Request) -> Result { state.importing.cancel(); Ok(Response::from(Outcome::Goto(Action::get(BACK)))) } // --- the flow --- /// `GET /import` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(flow(state).into()) } /// `GET /import/open` /// /// The Import menu, as an overlay. The shipped one is a popup anchored to the /// button that opened it, and this is the **second consumer** of the note /// [`toolbar`](super::toolbar)'s header left on that: `Outcome::Over` is /// app-modal, an anchored popover is not, and the difference is where a host /// draws it rather than what it holds. fn open(_state: &Panels<'_>, _request: Request) -> Result { let body = Slot::new("import-menu", RegionKind::Pane) .with(Node::page("Import")) // Each entry says what it does rather than what it is called, which is // the shipped popup's own correction: "Import folder..." means the // wizard and "Quick import" means the fast path, and the two used to be // told apart by a tooltip. .with(Node::Act(Act::new( "Import folder...", Action::post("/import/open/folder"), ))) .with(Node::text( "Choose a folder, pick where it lands, then tag and analyse what came in.", )) .with(Node::Act(Act::new( "Quick import folder...", Action::post("/import/open/quick"), ))) .with(Node::text( "Index a folder where it sits, with no strategy or tagging review.", )) .with(Node::Act(Act::new( "Import files...", Action::post("/import/open/files"), ))) .with(Node::text( "Merge chosen files into the vault that is open.", )) .with(Node::Act(Act::new("Close", Action::get(BACK)).key("esc"))); Ok(Response::over(Screen::sidebar_content("Import").with(body))) } /// `POST /import/open/folder` fn open_folder(state: &Panels<'_>, _request: Request) -> Result { state.importing.open_folder(); Ok(handed_off()) } /// `POST /import/open/quick` fn open_quickly(state: &Panels<'_>, _request: Request) -> Result { state.importing.open_quickly(); Ok(handed_off()) } /// `POST /import/open/files` fn open_files(state: &Panels<'_>, _request: Request) -> Result { state.importing.open_files(); Ok(handed_off()) } /// `POST /import/source` /// /// Refused unless something is being configured: the source is what the /// configure screen is about, and there is no honest screen for changing the /// source of an import that is already copying. fn change_source(state: &Panels<'_>, _request: Request) -> Result { if !matches!(state.importing.stage(), Stage::Configuring { .. }) { return Err(RouteError::not_found("nothing is being configured")); } state.importing.change_source(); Ok(handed_off()) } /// What a door answers, having asked the host. /// /// See the module header: no outcome says "nothing here changed", so it says /// where the answer will be once the picker returns. fn handed_off() -> Response { Response::from(Outcome::Goto(Action::get(FLOW_HOME))) } /// `POST /import/set/{decision}` /// /// One route for all three of the configure screen's answers, which is /// [`export`](super::export)'s arrangement and [`Decision`] is what closes the /// set so the route carries no second list of what it will name. fn decide(state: &Panels<'_>, request: Request) -> Result { let name = request.captures.require("decision")?; let decision = Decision::from_key(name).ok_or_else(|| RouteError::not_found("no such import setting"))?; let value = request .payload .get(name) .or_else(|| request.payload.get(Node::SELECTED)) .unwrap_or_default(); if decision == Decision::Strategy && Strategy::from_key(value).is_none() { return Err(RouteError::not_found("no such import strategy")); } state.importing.decide(decision, value); Ok(flow(state).into()) } /// `POST /import/start` /// /// Refused where the shipped button is disabled, and it is the same two /// conditions: a new vault with no name has nowhere to put the files, and a /// merge with no vaults would index past the end of the list the picker was /// built from. A disabled control the reader could still reach by typing the /// address is not disabled. fn start(state: &Panels<'_>, _request: Request) -> Result { let Stage::Configuring { strategy, vault_name, vaults, .. } = state.importing.stage() else { return Err(RouteError::not_found("nothing is being configured")); }; if let Some(reason) = unready(strategy, &vault_name, &vaults) { return Err(RouteError::not_found(reason)); } state.importing.begin(); Ok(flow(state).into()) } /// `POST /import/stop` fn stop(state: &Panels<'_>, _request: Request) -> Result { state.importing.stop(); Ok(flow(state).into()) } /// `POST /import/retry` fn retry(state: &Panels<'_>, _request: Request) -> Result { state.importing.retry(); Ok(flow(state).into()) } /// `POST /import/dismiss` fn dismiss(state: &Panels<'_>, _request: Request) -> Result { state.importing.dismiss(); Ok(flow(state).into()) } /// `POST /import/folders/{at}/tags` fn tag_folder(state: &Panels<'_>, request: Request) -> Result { let at = folder_at(state, &request)?; let typed = request.payload.get(TAGS).unwrap_or_default(); state.importing.tag_folder(at, typed); Ok(flow(state).into()) } /// `POST /import/folders/all` /// /// The shipped screen's "Apply to all", which copies one typed string into /// every folder's own input. Described as a write to all of them rather than as /// a field with its own life: `tag_folders_apply_all_input` is a `String` on /// `BrowserState` that exists to be typed into and cleared, and what is being /// typed into a described screen is the runtime's. Same deletion the preflight's /// checkbox made. fn tag_every_folder(state: &Panels<'_>, request: Request) -> Result { let typed = request.payload.get(TAGS).unwrap_or_default().trim(); if typed.is_empty() { return Err(RouteError::not_found("type at least one tag to apply")); } state.importing.tag_every_folder(typed); Ok(flow(state).into()) } /// `POST /import/folders/apply` /// /// Refused where every input is empty, which is what the shipped button is /// disabled on and for its stated reason: applying nothing is Skip wearing /// another label, and Skip is the explicit discard path. fn apply_folder_tags(state: &Panels<'_>, _request: Request) -> Result { let Stage::Tagging { folders } = state.importing.stage() else { return Err(RouteError::not_found("no folders are being tagged")); }; if folders.iter().all(|folder| folder.typed.trim().is_empty()) { return Err(RouteError::not_found("nothing has been typed to apply")); } state.importing.apply_folder_tags(); Ok(flow(state).into()) } /// `POST /import/folders/skip` fn skip_folder_tags(state: &Panels<'_>, _request: Request) -> Result { state.importing.skip_folder_tags(); Ok(flow(state).into()) } /// `POST /import/measure/{measure}` fn measure(state: &Panels<'_>, request: Request) -> Result { let name = request.captures.require("measure")?; let measure = Measure::from_key(name).ok_or_else(|| RouteError::not_found("no such measure"))?; // A checkbox submits its value or nothing, and non-empty is on. The reading // `export`'s `Setting::Sidecar` makes, rather than a literal "on" this side // would then have to agree with three renderers about. let wanted = !request.payload.get(name).unwrap_or_default().is_empty(); state.importing.measure(measure, wanted); Ok(flow(state).into()) } /// `POST /import/analyse` fn analyse(state: &Panels<'_>, _request: Request) -> Result { state.importing.analyse(); Ok(flow(state).into()) } /// `POST /import/analyse/back` /// /// Refused where the tags of the previous step were never stashed, which is what /// the shipped Back button is disabled on: the flow was entered somewhere other /// than a folder import and there is no tagging step behind it. fn back_to_tagging(state: &Panels<'_>, _request: Request) -> Result { let Stage::Choosing { resumable, .. } = state.importing.stage() else { return Err(RouteError::not_found("no analysis is being configured")); }; if !resumable { return Err(RouteError::not_found( "there is no tagging step to go back to", )); } state.importing.back_to_tagging(); Ok(flow(state).into()) } /// `POST /import/analyse/skip` fn skip_analysis(state: &Panels<'_>, _request: Request) -> Result { state.importing.skip_analysis(); Ok(flow(state).into()) } /// `POST /import/analyse/stop` fn stop_analysis(state: &Panels<'_>, _request: Request) -> Result { state.importing.stop_analysis(); Ok(flow(state).into()) } /// `POST /import/analyse/retry` fn retry_analysis(state: &Panels<'_>, _request: Request) -> Result { state.importing.retry_analysis(); Ok(flow(state).into()) } /// `POST /import/review/order` fn order(state: &Panels<'_>, request: Request) -> Result { let chosen = request .payload .get(Node::SELECTED) .or_else(|| request.payload.get("order")) .unwrap_or_default(); let order = Order::from_key(chosen).ok_or_else(|| RouteError::not_found("no such order"))?; state.importing.order(order); Ok(flow(state).into()) } /// `POST /import/review/{at}/read` fn read(state: &Panels<'_>, request: Request) -> Result { let at = reviewed_at(state, &request)?; state.importing.read(at); Ok(flow(state).into()) } /// `POST /import/review/{at}/judge` /// /// **The tag rides in the payload rather than in the address**, which is a /// deliberate departure from [`detail`](super::detail)'s /// `/detail/tags/{tag}/remove`. A tag here is a `tagtree` path and may hold a /// `/`, so a tag in a path segment is an address that stops matching the moment /// somebody suggests a nested one. Naming it as a value is what a value is for. /// /// **It flips rather than sets**, and that is the renderer's shape rather than a /// preference: [`Row::toggling`](quasi_router::Row::toggling) says the tick *is* /// the write, and a renderer firing it sends the action with no state of its own /// — the new value is not a thing the row submits. So the route reads what is /// true now and answers with the other one, which is also the only reading that /// survives two ticks in one frame. fn judge(state: &Panels<'_>, request: Request) -> Result { let at = reviewed_at(state, &request)?; let tag = request.payload.get(TAG).unwrap_or_default(); let Stage::Reviewing { items, .. } = state.importing.stage() else { return Err(RouteError::not_found("nothing is being reviewed")); }; let held = items .get(at) .and_then(|item| item.suggestions.iter().find(|held| held.tag == tag)) .ok_or_else(|| RouteError::not_found("no such suggestion"))?; state.importing.judge(at, tag, !held.accepted); Ok(flow(state).into()) } /// `POST /import/review/all/accept` fn accept_all(state: &Panels<'_>, _request: Request) -> Result { state.importing.judge_all(true); Ok(flow(state).into()) } /// `POST /import/review/all/reject` fn reject_all(state: &Panels<'_>, _request: Request) -> Result { state.importing.judge_all(false); Ok(flow(state).into()) } /// `POST /import/review/apply` /// /// Refused with nothing accepted, which is what the shipped button is disabled /// on: applying zero tags is a control that does nothing while looking like it /// commits. fn apply_suggestions(state: &Panels<'_>, _request: Request) -> Result { let Stage::Reviewing { items, .. } = state.importing.stage() else { return Err(RouteError::not_found("nothing is being reviewed")); }; if accepted_of(&items) == 0 { return Err(RouteError::not_found("no suggestions have been accepted")); } state.importing.apply_suggestions(); Ok(flow(state).into()) } /// `POST /import/review/discard` fn discard_suggestions(state: &Panels<'_>, _request: Request) -> Result { state.importing.discard_suggestions(); Ok(flow(state).into()) } /// `POST /import/summary/keep` fn keep_failed(state: &Panels<'_>, _request: Request) -> Result { state.importing.keep_failed(); Ok(flow(state).into()) } /// `POST /import/summary/purge` fn purge_all(state: &Panels<'_>, _request: Request) -> Result { state.importing.purge_failed(None); Ok(flow(state).into()) } /// `POST /import/summary/{at}/purge` fn purge_one(state: &Panels<'_>, request: Request) -> Result { let at: usize = request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("no such file"))?; let Stage::Summary { unanalysed, .. } = state.importing.stage() else { return Err(RouteError::not_found("there is no summary")); }; if at >= unanalysed.len() { return Err(RouteError::not_found("no such file")); } state.importing.purge_failed(Some(at)); Ok(flow(state).into()) } /// `GET /cleanup` /// /// A refusal when nothing is being swept, for the preflight's reason: the /// address is reachable by typing and there is no honest screen for "no sweep is /// running". fn sweeping(state: &Panels<'_>, _request: Request) -> Result { let sweep = state .importing .sweeping() .ok_or_else(|| RouteError::not_found("nothing is being cleaned up"))?; Ok(Screen::sidebar_content("Cleaning up") .with(sweep_body(&sweep)) .into()) } /// `POST /cleanup/stop` fn stop_sweep(state: &Panels<'_>, _request: Request) -> Result { state.importing.stop_sweep(); Ok(Screen::sidebar_content("Cleaning up") .with(match state.importing.sweeping() { Some(sweep) => sweep_body(&sweep), None => { Slot::new(SWEEP, RegionKind::Pane).with(Node::empty("Nothing is being cleaned up.")) } }) .into()) } // --- the screens --- /// Whichever of the nine screens the flow is on. fn flow(state: &Panels<'_>) -> Screen { let body = match state.importing.stage() { Stage::Idle => idle(), Stage::Configuring { source, files, strategy, vault_name, vaults, merging_into, } => configuring(&source, files, strategy, &vault_name, &vaults, merging_into), Stage::Scanning { found, size } => scanning(found, size.as_deref()), Stage::Copying { done, total, current, size, in_place, failures, } => copying(done, total, ¤t, size.as_deref(), in_place, &failures), Stage::Tagging { folders } => tagging(&folders), Stage::Choosing { samples, measures, resumable, } => choosing(samples, measures, resumable), Stage::Analysing { done, total, current, failures, } => analysing(done, total, ¤t, &failures), Stage::Reviewing { items, at, order } => reviewing(&items, at, order), Stage::Summary { rejected, unanalysed, } => summary(&rejected, &unanalysed), Stage::Stopped { what, done, total } => stopped(what, done, total), }; Screen::sidebar_content("Import").with(body) } /// Nothing is being imported. /// /// A way in rather than a bare sentence, which is where this screen differs from /// [`export`](super::export)'s idle: an export is entered by selecting samples in /// a different screen and there is nothing honest to offer here, while an import /// is entered by choosing a folder and that is a control. fn idle() -> Slot { Slot::new(FLOW, RegionKind::Pane).with( Node::empty("Nothing is being imported.") .offering(Act::new("Import...", Action::get("/import/open"))), ) } /// Where the files are going, before any of them go. fn configuring( source: &str, files: usize, strategy: Strategy, vault_name: &str, vaults: &[VaultChoice], merging_into: usize, ) -> Slot { let mut body = Slot::new(FLOW, RegionKind::Pane) .with(rail(0)) .with(Node::page("Import Folder")) .with(Node::text(format!("Source: {source}"))) .with(Node::Act(Act::new( "Change...", Action::post("/import/source"), ))) .with(Node::text(format!( "{files} audio file{} found", if files == 1 { "" } else { "s" } ))) .with(Node::text(format!( "Supported: {}. Duplicates will be skipped automatically.", audiofiles_core::util::AUDIO_EXTENSIONS.join(", ") ))) .with(strategy_field(strategy, vaults)); // The follow-up the chosen strategy opens, and only that one. The shipped // screen does the same and the reason is the settings screen's: a control // that cannot be used is worse than one that is not there. match strategy { Strategy::NewVault => { let mut field = Field::new(FieldKind::Text, Decision::VaultName.as_str(), "Vault name") .required() .hint("e.g. Drum Kits") .value(vault_name) .changes(writes(Decision::VaultName)); // The state the shipped screen used to leave unexplained: Import // disabled itself and said why only to a pointer that hovered it. if vault_name.trim().is_empty() { field = field.error("Enter a name for the new vault."); } body = body.with(Node::Field(Box::new(field))); } Strategy::Merge if vaults.is_empty() => { // Reachable only by arriving here with the strategy already set, // since the choice itself is refused above. Said as a banner rather // than as an error on a picker that has nothing to pick from. body = body.with(Node::banner( Tone::Danger, "No existing vaults to merge into.", )); } Strategy::Merge => { let options: Vec = vaults .iter() .enumerate() .map(|(at, vault)| Choice::new(at.to_string(), vault.name.clone())) .collect(); let mut field = Field::select(Decision::MergeVault.as_str(), "Merge into", options) .changes(writes(Decision::MergeVault)); field.value = Some(merging_into.to_string()); body = body.with(Node::Field(Box::new(field))); } Strategy::Flat => {} } // The one-way edge, said before the control that crosses it. Configure to // Importing is the only transition in this flow that cannot be walked back: // cancelling mid-copy keeps what has landed rather than rolling it back, so // Import is a commit and the sentence is what stops it reading as a preview. body = body.with(Node::banner( Tone::Warning, "Once started, you can cancel mid-import but copies already made will stay in the library.", )); let mut go = Act::new("Import", Action::post("/import/start")); if let Some(reason) = unready(strategy, vault_name, vaults) { // The reason is on the field already, which is why this one is only // disabled. See the module header's second finding for the three // controls where it is not. let _ = reason; go = go.disabled(); } body.with(Node::Act(go)).with(Node::Act(Act::new( "Cancel", Action::post("/import/dismiss"), ))) } /// Where the files land, as three answers rather than a widget. /// /// A radio and not a dropdown, and the shipped screen's own comment is the /// argument: this decides where every imported file goes and cannot be revised /// without re-importing, so all three answers have to be readable without /// opening anything. fn strategy_field(strategy: Strategy, vaults: &[VaultChoice]) -> Node { let merge = Choice::new(Strategy::Merge.as_str(), "Merge into existing vault"); // `Choice::unless` rather than an error under the picker: the option cannot // be taken and it says why on itself, which is the member the module // header's second finding wishes `Act` had. let merge = if vaults.is_empty() { merge.unless("No existing vaults to merge into.") } else { merge }; let options = vec![ Choice::new( Strategy::Flat.as_str(), "Flat (all files in current directory)", ), Choice::new( Strategy::NewVault.as_str(), "New vault (preserve directory structure)", ), merge, ]; Node::Field(Box::new( Field::radio(Decision::Strategy.as_str(), "Import strategy", options) .value(strategy.as_str()) .changes(writes(Decision::Strategy)), )) } /// Walking the folder, before there is a count. fn scanning(found: usize, size: Option<&str>) -> Slot { let said = match (found, size) { (0, _) => "Scanning for audio files...".to_owned(), (found, Some(size)) => format!("Scanning for audio files... {found} found ({size})"), (found, None) => format!("Scanning for audio files... {found} found"), }; Slot::new(FLOW, RegionKind::Pane) .with(rail(0)) .with(Node::page("Importing Folder...")) .with(Node::StandIn { state: Readiness::Pending, message: said, act: None, }) // The reason the control below is dead. `Act::disabled` cannot carry it, // so it is a line of its own: see the module header's second finding. .with(Node::text( "Cancel becomes available once the scan completes.", )) .with(Node::Act( Act::new("Cancel", Action::post("/import/stop")).disabled(), )) } /// Files being copied in. fn copying( done: usize, total: usize, current: &str, size: Option<&str>, in_place: bool, failures: &[Failure], ) -> Slot { let mut body = Slot::new(FLOW, RegionKind::Pane) .with(rail(0)) .with(Node::page("Importing Folder...")); if let Some(size) = size { // Which of the two this says is the whole point of the line: an import // that references files where they sit costs no disk, and one that // copies them costs this much. body = body.with(Node::banner( if in_place { Tone::Warning } else { Tone::Info }, if in_place { format!("{total} files, {size} total (referenced in place, no copies)") } else { format!("{total} files, ~{size} will be duplicated into vault") }, )); } body = body .with(Node::Meter( Meter::new(clamp(done), clamp(total)).label("files"), )) .with(Node::Act(Act::new("Cancel", Action::post("/import/stop")))); if !current.is_empty() { body = body.with(Node::text(format!("Importing: {current}"))); } failed(body, failures, "/import/retry") } /// Naming what came in. fn tagging(folders: &[FolderTags]) -> Slot { let samples: usize = folders.iter().map(|folder| folder.samples).sum(); let mut body = Slot::new(FLOW, RegionKind::Pane) .with(rail(1)) .with(Node::page("Tag Imported Folders")) .with(Node::text(format!( "{} folders \u{b7} {samples} samples", folders.len() ))) .with(Node::text( "Assign tags to imported folders. Comma-separated. \ Applied to all samples within each folder.", )) // Broadcasting to every folder is a form rather than a field, because it // is submitted deliberately: a `changes` on it would copy a half-typed // tag string into every input on the way to the whole one. .with(Node::Form { fields: vec![ Field::new(FieldKind::Text, TAGS, "Apply to all").hint("e.g. one-shots, kick"), ], submit: "Apply to all".to_owned(), action: Action::post("/import/folders/all"), }); for (at, folder) in folders.iter().enumerate() { let mut group = Slot::new(format!("import-folder-{at}"), RegionKind::Group) .with(Node::section(format!( "{} ({} samples)", folder.name, folder.samples ))) .with(Node::Field(Box::new( Field::new(FieldKind::Text, TAGS, "Tags") .value(&folder.typed) .changes(Action::post(format!("/import/folders/{at}/tags"))), ))); if !folder.invalid.is_empty() { group = group.with(Node::banner( Tone::Danger, format!("Invalid: {}", folder.invalid.join(", ")), )); } body = body.with(Node::Region(group)); } let nothing_typed = folders.iter().all(|folder| folder.typed.trim().is_empty()); let mut apply = Act::new("Apply Tags", Action::post("/import/folders/apply")); if nothing_typed { body = body.with(Node::text("Add at least one tag, or use Skip.")); apply = apply.disabled(); } body.with(Node::Act(apply)).with(Node::Act(Act::new( "Skip", Action::post("/import/folders/skip"), ))) } /// What the analysis will measure. fn choosing(samples: usize, measures: Measures, resumable: bool) -> Slot { let mut body = Slot::new(FLOW, RegionKind::Pane) .with(rail(2)) .with(Node::page("Configure Analysis")) .with(Node::text(format!("{samples} samples to analyze"))); for measure in Measure::ALL { body = body.with(Node::Field(Box::new( Field::new(FieldKind::Checkbox, measure.as_str(), measure.label()) .value(if measure.read(&measures) { "on" } else { "" }) .changes(Action::post(format!( "/import/measure/{}", measure.as_str() ))), ))); } let mut back = Act::new("Back", Action::post("/import/analyse/back")); if !resumable { body = body.with(Node::text( "There is no tagging step behind this run to go back to.", )); back = back.disabled(); } body.with(Node::Act(back)) .with(Node::Act(Act::new( "Run Analysis", Action::post("/import/analyse"), ))) .with(Node::Act(Act::new( "Skip analysis", Action::post("/import/analyse/skip"), ))) } /// Samples being analysed. fn analysing(done: usize, total: usize, current: &str, failures: &[Failure]) -> Slot { let mut body = Slot::new(FLOW, RegionKind::Pane) .with(rail(2)) .with(Node::page("Analysing Samples...")) .with(Node::Meter( Meter::new(clamp(done), clamp(total)).label("samples"), )) .with(Node::Act(Act::new( "Cancel", Action::post("/import/analyse/stop"), ))); if !current.is_empty() { body = body.with(Node::text(format!("Analysing: {current}"))); } failed(body, failures, "/import/analyse/retry") } /// What has gone wrong so far, and the way to start over. /// /// One list for both error kinds, which is what the shipped `draw_error_log` /// does: a running screen reports how much is going wrong rather than at which /// stage it went. The shipped Hide/Show toggle is not described — whether a list /// is folded away is the reader's, and a described screen that carried it would /// be holding view state the runtime already holds. fn failed(body: Slot, failures: &[Failure], retry: &str) -> Slot { if failures.is_empty() { return body; } body.with(Node::banner( Tone::Danger, format!( "{} error{}", failures.len(), if failures.len() == 1 { "" } else { "s" } ), )) .with(Node::list(failures.iter().map(|failure| { Row::new(failure.name.clone()).secondary(Prose::Text(failure.error.clone())) }))) .with(Node::Act(Act::new("Retry", Action::post(retry.to_owned())))) } /// Reading what the analysis suggested. /// /// The one stage with two panes, and the region says so: [`RegionKind::Split`] /// is "two panes side by side, the left choosing what the right shows", which is /// what the shipped side panel and central pane are. The screen's own /// arrangement stays `sidebar_content` like every other stage — the split is /// inside this flow's one region rather than a different shape of screen, /// because the flow is one address and the arrangement is the window's. fn reviewing(items: &[Reviewed], at: usize, order: Order) -> Slot { let total: usize = items.iter().map(|item| item.suggestions.len()).sum(); let accepted = accepted_of(items); let mut head = Slot::new("review-head", RegionKind::Group) .with(Node::page("Review Tag Suggestions")) .with(Node::text(format!( "{} samples, {total} suggestions ({accepted} accepted)", items.len() ))) .with(Node::Act(Act::new( "Accept All", Action::post("/import/review/all/accept"), ))) .with(Node::Act(Act::new( "Reject All", Action::post("/import/review/all/reject"), ))); if let Some(stats) = batch(items) { head = head.with(stats); } let split = Slot::new("review-split", RegionKind::Split) .with(Node::Region(listing(items, at, order))) .with(Node::Region(reading(items, at))); let mut apply = Act::new( format!( "Apply {accepted} Tag{}", if accepted == 1 { "" } else { "s" } ), Action::post("/import/review/apply"), ); if accepted == 0 { apply = apply.disabled(); } let mut body = Slot::new(FLOW, RegionKind::Pane) .with(rail(3)) .with(Node::Region(head)) .with(Node::Region(split)); if accepted == 0 { body = body.with(Node::text( "Accept at least one suggestion, or use Cancel to discard.", )); } body.with(Node::Act(apply)).with(Node::Act(Act::new( "Cancel", Action::post("/import/review/discard"), ))) } /// What the batch looks like as a whole. /// /// Describable because it is arithmetic over results the screen already carries: /// the tempo range and the three most common keys, which is what lets a reader /// see whether the analysis looks sane before committing any of it. fn batch(items: &[Reviewed]) -> Option { let mut figures = Vec::new(); let tempos: Vec = items.iter().filter_map(|item| item.bpm).collect(); if let (Some(low), Some(high)) = ( tempos.iter().copied().reduce(f64::min), tempos.iter().copied().reduce(f64::max), ) { let said = if low < high { format!("{low:.0} - {high:.0}") } else { format!("{low:.0}") }; figures.push((Figure::new(said, "BPM"), None)); } // A `BTreeMap` so the tie-break is by name rather than by whichever the hash // happened to put first, which is the shipped screen's ordering and the only // one that answers the same way twice. let mut keys: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); for item in items { if let Some(key) = item.musical_key.as_deref() { *keys.entry(key).or_default() += 1; } } for (key, count) in keys.into_iter().take(3) { figures.push((Figure::new(count.to_string(), key.to_owned()), None)); } (!figures.is_empty()).then_some(Node::Stats { figures }) } /// Every sample with something to say about it. fn listing(items: &[Reviewed], at: usize, order: Order) -> Slot { let mut list = Slot::new(REVIEW_LIST, RegionKind::Pane).with(Node::Select { kind: Selector::Segmented, options: Order::ALL .into_iter() .map(|order| (Choice::new(order.as_str(), order.label()), None)) .collect(), chosen: Some(order.as_str().to_owned()), action: Some(Action::post("/import/review/order")), }); if items.is_empty() { return list.with(Node::empty("Nothing was analysed.")); } // Ordered here, and it is the second thing this flow sorts for the reason // the first was: `sorted_indices` builds a display order the shipped screen // reads through so `current_idx` keeps pointing at the underlying item. // Rows carry their own index, so the order is presentation and the address // is not. let mut order_of: Vec = (0..items.len()).collect(); match order { Order::Arrival => {} Order::Name => order_of.sort_by_key(|&at| items[at].name.to_lowercase()), Order::Suggestions => { order_of.sort_by_key(|&at| std::cmp::Reverse(items[at].suggestions.len())); } Order::Accepted => order_of.sort_by_key(|&at| std::cmp::Reverse(accepted_in(&items[at]))), } list = list.with(Node::list(order_of.into_iter().map(|held| { let item = &items[held]; let mut row = Row::new(item.name.clone()) .activate(Action::post(format!("/import/review/{held}/read"))); if !item.suggestions.is_empty() { row = row.meta(format!("{}/{}", accepted_in(item), item.suggestions.len())); } row }))); let _ = at; list } /// The one being read, and what is proposed for it. fn reading(items: &[Reviewed], at: usize) -> Slot { let pane = Slot::new(REVIEW_ITEM, RegionKind::Pane); let Some(item) = items.get(at) else { return pane.with(Node::empty("Choose a sample to review.")); }; let mut pane = pane .with(Node::section(item.name.clone())) .with(Node::text(format!("({} of {})", at + 1, items.len()))) .with(Node::text(facts(item))); if item.suggestions.is_empty() { return pane.with(Node::empty("Nothing was suggested for this sample.")); } pane = pane.with(Node::list(item.suggestions.iter().map(|held| { Row::new(held.tag.clone()) .secondary(Prose::Text(held.reason.clone())) // The confidence as a trailing fact rather than as a colour. The // shipped row bands it green/amber/muted at 80 and 60 per cent, // which is a renderer reading a number the description carries: what // is described is how sure the analysis is. .meta(format!("{:.0}%", held.confidence * 100.0)) .toggling( held.accepted, Action::post(format!("/import/review/{at}/judge")).carrying(TAG, held.tag.clone()), ) }))); pane } /// What analysis found about one sample, as one line. /// /// Joined rather than five nodes, for the reason [`export`](super::export)'s /// device profile is: the shipped screen's two rows of small labels are a layout /// choice, and what is described is one statement about one sample. fn facts(item: &Reviewed) -> String { let mut said = vec![ format!("{:.2}s", item.duration), format!("{}Hz", item.sample_rate), ]; if let Some(peak) = item.peak_db { said.push(format!("Peak: {peak:.1}dB")); } if let Some(bpm) = item.bpm { said.push(format!("{bpm:.1} BPM")); } if let Some(key) = item.musical_key.as_deref() { said.push(key.to_owned()); } said.join(" \u{b7} ") } /// What failed, once the run is over. fn summary(rejected: &[Failure], unanalysed: &[Failure]) -> Slot { let mut body = Slot::new(FLOW, RegionKind::Pane).with(Node::page("Import Summary")); // The two lists are different situations and the shipped screen's copy is // what says so: one is remediable from here and one is not. Kept as two // lists rather than folded into the one the progress screens show, because // what a finished screen reports is what to do about it. if !unanalysed.is_empty() { body = body .with(Node::banner( Tone::Danger, format!( "{} file{} failed analysis", unanalysed.len(), if unanalysed.len() == 1 { "" } else { "s" } ), )) .with(Node::text( "These files are in the library but couldn't be analysed. \ You can remove them, ignore them, or re-analyse later.", )) .with(Node::list(unanalysed.iter().enumerate().map( |(at, failure)| { Row::new(failure.name.clone()) .secondary(Prose::Text(failure.error.clone())) .offers( Act::new( "Remove", Action::post(format!("/import/summary/{at}/purge")), ) .tone(Tone::Danger) .confirm(format!( "\"{}\" will be permanently removed from the library. Remove?", failure.name )), ) }, ))); } if !rejected.is_empty() { body = body .with(Node::banner( Tone::Danger, format!( "{} file{} failed to import", rejected.len(), if rejected.len() == 1 { "" } else { "s" } ), )) .with(Node::text( "These files weren't imported. Re-running the import \ is the only way to retry \u{2014} duplicates will be skipped.", )) .with(Node::list(rejected.iter().map(|failure| { Row::new(failure.name.clone()).secondary(Prose::Text(failure.error.clone())) }))); } if rejected.is_empty() && unanalysed.is_empty() { body = body.with(Node::empty("Nothing failed.")); } body = body.with(Node::Act(Act::new( "Keep All", Action::post("/import/summary/keep"), ))); if unanalysed.is_empty() { return body; } body.with(Node::Act( Act::new("Remove All Failed", Action::post("/import/summary/purge")) .tone(Tone::Danger) .confirm(format!( "{} samples will be permanently removed from the library. Remove?", unanalysed.len() )), )) } /// Given up on partway. fn stopped(what: Halted, done: usize, total: usize) -> Slot { let (heading, noun, follow_up) = match what { Halted::Import => ( "Import cancelled", "files", "Imported files remain in the library. Re-run the import to add the rest \u{2014} \ duplicates will be skipped.", ), Halted::Analysis => ( "Analysis cancelled", "samples", "Analysed samples keep their results. The remaining samples are unanalysed \u{2014} \ run analysis again to complete them.", ), }; Slot::new(FLOW, RegionKind::Pane) .with(Node::page(heading)) .with(Node::text(format!("Stopped at {done} of {total} {noun}."))) .with(Node::text(follow_up)) .with(Node::Act(Act::new("Done", Action::post("/import/dismiss")))) } /// Orphaned samples being swept up. fn sweep_body(sweep: &Sweep) -> Slot { let mut body = Slot::new(SWEEP, RegionKind::Pane).with(Node::page("Cleaning Up Samples...")); // Zero is "the scan has not counted them yet" rather than an empty sweep, // and a meter of 0/0 would draw as finished. The reading `export`'s progress // screen settled. body = if sweep.total == 0 { body.with(Node::StandIn { state: Readiness::Pending, message: "Scanning for orphaned samples...".to_owned(), act: None, }) } else { body.with(Node::Meter( Meter::new(clamp(sweep.done), clamp(sweep.total)).label("samples"), )) }; if !sweep.current.is_empty() { body = body.with(Node::text(format!("Removing: {}", sweep.current))); } body.with(Node::Act(Act::new("Cancel", Action::post("/cleanup/stop")))) } // --- what the screens are built out of --- /// Where the flow is, as prose. /// /// The degraded stage rail. See the module header: the vocabulary says which /// child of a region is up and not who chose it, so a described rail would tell /// every renderer to offer navigation this flow does not have. The names and the /// position survive; the strip does not. fn rail(step: usize) -> Node { Node::text(format!( "Step {} of {}: {}", step + 1, STEPS.len(), STEPS[step] )) } /// The address a control changing this answers calls. fn writes(decision: Decision) -> Action { Action::post(format!("/import/set/{}", decision.as_str())) } /// Why the import cannot start yet, if it cannot. /// /// One function for the route's refusal and the control's disabled state, which /// is what stops the two disagreeing: a control that is enabled where the route /// refuses is a button that reports an error nobody could have avoided. fn unready(strategy: Strategy, vault_name: &str, vaults: &[VaultChoice]) -> Option<&'static str> { match strategy { Strategy::Flat => None, Strategy::NewVault => vault_name .trim() .is_empty() .then_some("enter a name for the new vault"), Strategy::Merge => vaults .is_empty() .then_some("there are no existing vaults to merge into"), } } /// The folder a request names, refusing one that is not there. fn folder_at(state: &Panels<'_>, request: &Request) -> Result { let at: usize = request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("no such folder"))?; let Stage::Tagging { folders } = state.importing.stage() else { return Err(RouteError::not_found("no folders are being tagged")); }; if at >= folders.len() { return Err(RouteError::not_found("no such folder")); } Ok(at) } /// The reviewed sample a request names, refusing one that is not there. fn reviewed_at(state: &Panels<'_>, request: &Request) -> Result { let at: usize = request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("no such sample"))?; let Stage::Reviewing { items, .. } = state.importing.stage() else { return Err(RouteError::not_found("nothing is being reviewed")); }; if at >= items.len() { return Err(RouteError::not_found("no such sample")); } Ok(at) } /// How many suggestions are accepted across the whole review. fn accepted_of(items: &[Reviewed]) -> usize { items.iter().map(accepted_in).sum() } /// How many of one sample's suggestions are accepted. fn accepted_in(item: &Reviewed) -> usize { item.suggestions.iter().filter(|held| held.accepted).count() } /// A count as the meter carries one. /// /// Saturating rather than `as`, for [`export`](super::export)'s reason: a /// truncating cast on a count that came from a worker is the kind of arithmetic /// that reads as fine and is not. fn clamp(count: usize) -> u32 { u32::try_from(count).unwrap_or(u32::MAX) }