Skip to main content

max / audiofiles

Import classifier asks through Outcome::Locate The sharing section's Import act asks for an .afcl and the answer lands on /classifier/sharing/imported, which reads it. The intent that opened the picker is gone, and the module header no longer files import beside export as a picker waiting on a gap.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 00:29 UTC
Signed with PGP, not checked
Commit: 89419018212ed7a850a77766c9dc2bfe0adc75ba
Parent: 1c76265
5 files changed, +103 insertions, -42 deletions
M Cargo.lock +16 -16
@@ -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