max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+103 insertions,
-42 deletions
| @@ -7554,22 +7554,6 @@ | |||
| 7554 | 7554 | "winnow 1.0.4", | |
| 7555 | 7555 | ] | |
| 7556 | 7556 | ||
| 7557 | - | [[patch.unused]] | |
| 7558 | - | name = "quasi-type" | |
| 7559 | - | version = "0.1.0" | |
| 7560 | - | ||
| 7561 | - | [[patch.unused]] | |
| 7562 | - | name = "kberg" | |
| 7563 | - | version = "0.1.0" | |
| 7564 | - | ||
| 7565 | - | [[patch.unused]] | |
| 7566 | - | name = "ops-status" | |
| 7567 | - | version = "0.1.0" | |
| 7568 | - | ||
| 7569 | - | [[patch.unused]] | |
| 7570 | - | name = "painhours" | |
| 7571 | - | version = "0.1.0" | |
| 7572 | - | ||
| 7573 | 7557 | [[patch.unused]] | |
| 7574 | 7558 | name = "quasi-axum" | |
| 7575 | 7559 | version = "0.62.0" | |
| @@ -7597,3 +7581,19 @@ | |||
| 7597 | 7581 | [[patch.unused]] | |
| 7598 | 7582 | name = "quasi-webview" | |
| 7599 | 7583 | version = "0.62.0" | |
| 7584 | + | ||
| 7585 | + | [[patch.unused]] | |
| 7586 | + | name = "kberg" | |
| 7587 | + | version = "0.1.0" | |
| 7588 | + | ||
| 7589 | + | [[patch.unused]] | |
| 7590 | + | name = "ops-status" | |
| 7591 | + | version = "0.1.0" | |
| 7592 | + | ||
| 7593 | + | [[patch.unused]] | |
| 7594 | + | name = "painhours" | |
| 7595 | + | version = "0.1.0" | |
| 7596 | + | ||
| 7597 | + | [[patch.unused]] | |
| 7598 | + | name = "quasi-type" | |
| 7599 | + | version = "0.1.0" |
| @@ -93,12 +93,13 @@ | |||
| 93 | 93 | //! built on a worker thread and answers through `BackendEvent`, so the route has | |
| 94 | 94 | //! no bytes to hand over when it answers. | |
| 95 | 95 | //! | |
| 96 | - | //! **Import is a conversion owed, not a gap.** It was written here citing | |
| 97 | - | //! `settings`'s bytes-in paragraph, and that paragraph turned out to be stale | |
| 98 | - | //! (measured 2026-08-25): `classifier_import_afcl` takes a *path*, so | |
| 99 | - | //! [`Outcome::Locate`]'s `Sought::File` covers it exactly, the way the Storage | |
| 100 | - | //! section's relocation already works. Both of these stay ordinary acts for now | |
| 101 | - | //! and both are on the list in [`quasi`](super)'s header. | |
| 96 | + | //! **Import is converted.** It was written here citing `settings`'s bytes-in | |
| 97 | + | //! paragraph, and that paragraph turned out to be stale (measured 2026-08-25): | |
| 98 | + | //! `classifier_import_afcl` takes a *path*, so [`Outcome::Locate`]'s | |
| 99 | + | //! `Sought::File` covers it exactly. [`import`](fn@import) asks for an `.afcl` | |
| 100 | + | //! and [`imported`](fn@imported) reads it, which is the act shape | |
| 101 | + | //! [`advanced`](super::advanced)'s Import Theme uses. Export stays an ordinary | |
| 102 | + | //! act, for the reason above, and is the one picker left in this module. | |
| 102 | 103 | //! | |
| 103 | 104 | //! [`Outcome::Locate`]: quasi_router::Outcome::Locate | |
| 104 | 105 | //! | |
| @@ -111,8 +112,8 @@ | |||
| 111 | 112 | ||
| 112 | 113 | use quasi_router::layout::{FieldKind, Notice, Readiness as Ready, Tone}; | |
| 113 | 114 | use quasi_router::{ | |
| 114 | - | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Row, | |
| 115 | - | Screen, Slot, Tag, | |
| 115 | + | Accepted, Act, Action, Choice, Field, Locating, Node, RegionKind, Request, Response, | |
| 116 | + | RouteError, Router, Row, Screen, Slot, Sought, Tag, | |
| 116 | 117 | }; | |
| 117 | 118 | ||
| 118 | 119 | use super::{Panels, Part, Shareable}; | |
| @@ -120,6 +121,12 @@ | |||
| 120 | 121 | /// The region the whole screen answers into. | |
| 121 | 122 | const BODY: &str = "classifier-body"; | |
| 122 | 123 | ||
| 124 | + | /// The suffix a shared classifier is written under. | |
| 125 | + | const SHARED: &str = ".afcl"; | |
| 126 | + | ||
| 127 | + | /// The name a picked file comes back under. | |
| 128 | + | const FILE: &str = "file"; | |
| 129 | + | ||
| 123 | 130 | /// What removing a layer takes with it. | |
| 124 | 131 | const DROPS_LAYER: &str = | |
| 125 | 132 | "Permanently delete this layer and its imported rules? This cannot be undone."; | |
| @@ -180,6 +187,7 @@ | |||
| 180 | 187 | .post("/classifier/sharing/include/{part}", include) | |
| 181 | 188 | .post("/classifier/sharing/export", export) | |
| 182 | 189 | .post("/classifier/sharing/import", import) | |
| 190 | + | .post("/classifier/sharing/imported", imported) | |
| 183 | 191 | .post("/classifier/layers/{id}/enabled", enable_layer) | |
| 184 | 192 | .post("/classifier/layers/{id}/weight", weigh_layer) | |
| 185 | 193 | .post("/classifier/layers/{id}/remove", remove_layer) | |
| @@ -508,8 +516,30 @@ | |||
| 508 | 516 | } | |
| 509 | 517 | ||
| 510 | 518 | /// `POST /classifier/sharing/import` | |
| 511 | - | fn import(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 512 | - | state.classifier.import(); | |
| 519 | + | /// | |
| 520 | + | /// Says a file is wanted and leaves the picker to the host. Reading it is | |
| 521 | + | /// [`imported`](fn@imported)'s, on the answer. | |
| 522 | + | fn import(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 523 | + | Ok(Response::locate(Locating::new( | |
| 524 | + | Sought::File { | |
| 525 | + | accept: vec![Accepted::suffix(SHARED)], | |
| 526 | + | }, | |
| 527 | + | "Import classifier", | |
| 528 | + | Action::post("/classifier/sharing/imported"), | |
| 529 | + | FILE, | |
| 530 | + | ))) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | /// `POST /classifier/sharing/imported` | |
| 534 | + | /// | |
| 535 | + | /// A reader who backed out of the picker has answered nothing: `ui::dialog` | |
| 536 | + | /// skips its handler on an empty result, so this is the second guard rather than | |
| 537 | + | /// the only one, and it is here because the address is reachable by typing. | |
| 538 | + | fn imported(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 539 | + | let file = request.payload.get(FILE).unwrap_or_default(); | |
| 540 | + | if !file.is_empty() { | |
| 541 | + | state.classifier.import(file); | |
| 542 | + | } | |
| 513 | 543 | settled(state) | |
| 514 | 544 | } | |
| 515 | 545 |
| @@ -1033,8 +1033,8 @@ | |||
| 1033 | 1033 | Include(Shareable, bool), | |
| 1034 | 1034 | /// Write the export. | |
| 1035 | 1035 | ExportClassifier, | |
| 1036 | - | /// Read someone else's. | |
| 1037 | - | ImportClassifier, | |
| 1036 | + | /// Read someone else's, from the file the reader picked. | |
| 1037 | + | ImportClassifierFrom(std::path::PathBuf), | |
| 1038 | 1038 | /// Turn a layer on or off. | |
| 1039 | 1039 | EnableLayer(String, bool), | |
| 1040 | 1040 | /// Say how much a layer counts. | |
| @@ -5061,8 +5061,8 @@ | |||
| 5061 | 5061 | /// Write the export. | |
| 5062 | 5062 | fn export(&self); | |
| 5063 | 5063 | ||
| 5064 | - | /// Read someone else's. | |
| 5065 | - | fn import(&self); | |
| 5064 | + | /// Read someone else's, out of this file. | |
| 5065 | + | fn import(&self, path: &str); | |
| 5066 | 5066 | ||
| 5067 | 5067 | /// Turn a layer on or off. | |
| 5068 | 5068 | fn enable_layer(&self, id: &str, on: bool); | |
| @@ -5663,8 +5663,8 @@ | |||
| 5663 | 5663 | self.push(Intent::ExportClassifier); | |
| 5664 | 5664 | } | |
| 5665 | 5665 | ||
| 5666 | - | fn import(&self) { | |
| 5667 | - | self.push(Intent::ImportClassifier); | |
| 5666 | + | fn import(&self, path: &str) { | |
| 5667 | + | self.push(Intent::ImportClassifierFrom(std::path::PathBuf::from(path))); | |
| 5668 | 5668 | } | |
| 5669 | 5669 | ||
| 5670 | 5670 | fn enable_layer(&self, id: &str, on: bool) { |
| @@ -1609,13 +1609,10 @@ | |||
| 1609 | 1609 | |state, path| state.classifier_export_afcl(&path), | |
| 1610 | 1610 | ); | |
| 1611 | 1611 | } | |
| 1612 | - | Intent::ImportClassifier => { | |
| 1613 | - | state.dialogs.pick_file( | |
| 1614 | - | "Import classifier", | |
| 1615 | - | &[("AF classifier", &["afcl"])], | |
| 1616 | - | |state, path| state.classifier_import_afcl(&path), | |
| 1617 | - | ); | |
| 1618 | - | } | |
| 1612 | + | // The picker is `locate` above: the sharing section asks for an | |
| 1613 | + | // `.afcl` with `Outcome::Locate` and this lands with the file that | |
| 1614 | + | // came back. | |
| 1615 | + | Intent::ImportClassifierFrom(path) => state.classifier_import_afcl(&path), | |
| 1619 | 1616 | Intent::EnableLayer(id, on) => state.classifier_set_layer_enabled(&id, on), | |
| 1620 | 1617 | Intent::WeighLayer(id, weight) => state.classifier_set_layer_weight(&id, weight), | |
| 1621 | 1618 | Intent::RemoveLayer(id) => state.classifier_remove_layer(&id), |
| @@ -5986,7 +5986,7 @@ | |||
| 5986 | 5986 | fn name_export(&self, _name: &str) {} | |
| 5987 | 5987 | fn include(&self, _part: Shareable, _on: bool) {} | |
| 5988 | 5988 | fn export(&self) {} | |
| 5989 | - | fn import(&self) {} | |
| 5989 | + | fn import(&self, _path: &str) {} | |
| 5990 | 5990 | fn enable_layer(&self, _id: &str, _on: bool) {} | |
| 5991 | 5991 | fn weigh_layer(&self, _id: &str, _weight: f64) {} | |
| 5992 | 5992 | fn remove_layer(&self, _id: &str) {} | |
| @@ -10803,8 +10803,8 @@ | |||
| 10803 | 10803 | fn export(&self) { | |
| 10804 | 10804 | self.note("export"); | |
| 10805 | 10805 | } | |
| 10806 | - | fn import(&self) { | |
| 10807 | - | self.note("import"); | |
| 10806 | + | fn import(&self, path: &str) { | |
| 10807 | + | self.note(format!("import {path}")); | |
| 10808 | 10808 | } | |
| 10809 | 10809 | fn enable_layer(&self, id: &str, on: bool) { | |
| 10810 | 10810 | self.note(format!("enable_layer {id} {on}")); | |
| @@ -11397,6 +11397,40 @@ | |||
| 11397 | 11397 | assert_eq!(classifier.asked(), ["export"]); | |
| 11398 | 11398 | } | |
| 11399 | 11399 | ||
| 11400 | + | #[test] | |
| 11401 | + | fn importing_a_classifier_asks_the_host_for_an_afcl_and_reads_it_on_the_answer() { | |
| 11402 | + | // The act shape of `Outcome::Locate`: picking the file *is* the import, so | |
| 11403 | + | // the call that lands does the reading rather than stashing a path. | |
| 11404 | + | let classifier = FakeClassifier::default(); | |
| 11405 | + | let response = | |
| 11406 | + | classifying(&classifier, Request::post("/classifier/sharing/import")).expect("answered"); | |
| 11407 | + | let Outcome::Locate(asking) = &response.outcome else { | |
| 11408 | + | panic!("expected an ask, got {:?}", response.outcome); | |
| 11409 | + | }; | |
| 11410 | + | assert_eq!(asking.answers.route(), Some("/classifier/sharing/imported")); | |
| 11411 | + | let quasi_router::Sought::File { accept } = &asking.sought else { | |
| 11412 | + | panic!("wanted a file, asked for {:?}", asking.sought); | |
| 11413 | + | }; | |
| 11414 | + | assert_eq!( | |
| 11415 | + | accept.len(), | |
| 11416 | + | 1, | |
| 11417 | + | "the picker was not narrowed to classifiers" | |
| 11418 | + | ); | |
| 11419 | + | assert!(classifier.asked().is_empty(), "the ask imported something"); | |
| 11420 | + | ||
| 11421 | + | let answered = Request::post("/classifier/sharing/imported") | |
| 11422 | + | .sending(Params::new().with("file".to_owned(), "/tmp/theirs.afcl".to_owned())); | |
| 11423 | + | classifying(&classifier, answered).expect("answered"); | |
| 11424 | + | assert_eq!(classifier.asked(), ["import /tmp/theirs.afcl"]); | |
| 11425 | + | } | |
| 11426 | + | ||
| 11427 | + | #[test] | |
| 11428 | + | fn backing_out_of_the_classifier_picker_imports_nothing() { | |
| 11429 | + | let classifier = FakeClassifier::default(); | |
| 11430 | + | classifying(&classifier, Request::post("/classifier/sharing/imported")).expect("answered"); | |
| 11431 | + | assert!(classifier.asked().is_empty()); | |
| 11432 | + | } | |
| 11433 | + | ||
| 11400 | 11434 | #[test] | |
| 11401 | 11435 | fn removing_a_layer_asks_on_the_control_rather_than_arming_a_second_one() { | |
| 11402 | 11436 | // `pending_layer_remove` was the fifth arm-then-confirm state machine this |