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