From ea6fdaee13fe9ae94c815587d3ac976215376dc1 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:20:06 -0400 Subject: [PATCH 1/8] tui: plugins' commands in the palette and the drive menu, a form for their params, answers in a panel, and their logs in the messages --- apps/locallibrary/CHANGELOG.md | 6 +- apps/locallibrary/docs/player.md | 9 +- apps/locallibrary/docs/plugins.md | 2 + apps/locallibrary/src/plugin_cmd.rs | 2 +- apps/locallibrary/src/plugin_cmd/view.rs | 50 +++-- apps/locallibrary/src/plugins.rs | 50 ++++- apps/locallibrary/src/tui/app.rs | 36 +++- apps/locallibrary/src/tui/app/testing.rs | 19 ++ apps/locallibrary/src/tui/drives.rs | 13 +- apps/locallibrary/src/tui/edits.rs | 5 +- apps/locallibrary/src/tui/form.rs | 175 +++++++++++++++++ apps/locallibrary/src/tui/form/tests.rs | 161 +++++++++++++++ apps/locallibrary/src/tui/keymap.rs | 7 +- apps/locallibrary/src/tui/mod.rs | 5 + apps/locallibrary/src/tui/palette.rs | 5 +- apps/locallibrary/src/tui/plugins.rs | 159 +++++++++++++++ apps/locallibrary/src/tui/plugins/tests.rs | 218 +++++++++++++++++++++ apps/locallibrary/src/tui/screens.rs | 32 +++ apps/locallibrary/src/tui/ui.rs | 74 ++++++- crates/locallibrary/src/plugin.rs | 2 +- 20 files changed, 976 insertions(+), 54 deletions(-) create mode 100644 apps/locallibrary/src/tui/form.rs create mode 100644 apps/locallibrary/src/tui/form/tests.rs create mode 100644 apps/locallibrary/src/tui/plugins.rs create mode 100644 apps/locallibrary/src/tui/plugins/tests.rs diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index bdb07989..accc2eac 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -214,8 +214,10 @@ Windows builds are experimental and untested on hardware (see Platforms in the R sidecar has all of it as `plugins.*` methods, their plans kept apart from locallibrary's own, and `track.fields` reads the installed plugins' versions. A plugin whose id or commands clash with locallibrary's or another plugin's is left out, with a warning saying why, and the others - work; a plugin's errors keep their kinds (`not_found`, `io`...). See docs/plugins.md. The ones - it ships: + work; a plugin's errors keep their kinds (`not_found`, `io`...). In the player, plugins' + commands are in the palette and a drive's menu, asking for their params in a form drawn from + their schemas and answering in a panel, and what plugins log goes to its messages (`!`). See + docs/plugins.md. The ones it ships: - `tracklist`: `playlist import` makes a playlist from a tracklist (M3U, CSV exports of streaming playlists, rekordbox text or XML, "Artist - Title" lines), and `tracklist match` shows what it finds in one, entry by entry, changing nothing. diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index 2863b20c..7b0bee56 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -27,6 +27,13 @@ listening to your library the way the players see it: `fsp` finds "fill the smart playlists") and enter. It also has the commands with no key of their own: pull, check, set prep, sync, pack, restore, replay, export, xml, smart, smart apply, smart new and eject. +- **Plugins' commands** are in the palette by title, with the plugin's name beside them, and at + the end of a drive's `e` menu in the Drives view. One that takes params asks for them in a form + first (`↑↓` or `tab` between fields, `←→` or `space` to pick a choice, `enter` to run; lists + take commas). It runs on the drive open, with the marked or selected tracks and the playlist + shown, in the background (`esc` stops it), and what it answers shows in a panel, or as a + message when it's a line. A plugin that reads the drive needs one plugged in, not a folder + opened by path. What plugins log goes to the messages (`!`). See [Plugins](plugins.md). - **Problems**: ✖ marks a track whose audio file isn't on the drive, ≈ one the drive holds another copy of. Browse › Problems lists them. - **Playback** of MP3, AAC/ALAC (`.m4a`), FLAC, WAV and AIFF, with a queue built from the list you @@ -174,7 +181,7 @@ output, `o` goes to a track, `w` writes a file, `e` opens the Library and Drives | `l` | loop here / exit the loop (`(` `)`: halve / double it) | | `+` `-` | volume (`=` `_` too) | | `d` | the output to play through | -| `:` | commands: every action by name, and those with no key | +| `:` | commands: every action by name, those with no key, and plugins' commands | | `s` `r` | shuffle / repeat (off, all, one) | | `S` | autoplay off (single play: stop after each track) / on | | `A` | auto cue: start tracks at their first memory cue or beat | diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index 51da46ad..7cd1e1db 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -49,6 +49,8 @@ with each other are both left out. plugin's options as `--option KEY=VALUE`. `--dry-run` lists what it would write (each file, what goes in, what the format can't hold), and overwriting a file is asked about first unless `--replace`. +- **In the player** (`locallibrary tui`), commands are in the palette (`:`) and a drive's menu, + with a form for their params (see [the player](player.md)). - **Settings**: `locallibrary plugin settings ID` shows them, `plugin settings ID KEY VALUE` sets one and `plugin settings ID KEY --reset` puts it back to its default. They're kept in the store (`plugins/ID/settings.json`). diff --git a/apps/locallibrary/src/plugin_cmd.rs b/apps/locallibrary/src/plugin_cmd.rs index 0e95cb50..26e7d172 100644 --- a/apps/locallibrary/src/plugin_cmd.rs +++ b/apps/locallibrary/src/plugin_cmd.rs @@ -6,7 +6,7 @@ mod analyze; mod contributed; mod listing; -mod view; +pub(crate) mod view; use std::path::PathBuf; use std::process::ExitCode; diff --git a/apps/locallibrary/src/plugin_cmd/view.rs b/apps/locallibrary/src/plugin_cmd/view.rs index f5c10661..c3898232 100644 --- a/apps/locallibrary/src/plugin_cmd/view.rs +++ b/apps/locallibrary/src/plugin_cmd/view.rs @@ -1,6 +1,6 @@ //! What a plugin's command answers, drawn as the CLI draws its own results. -use loclib_plugin::View; +use loclib_plugin::{Pair, View}; use serde_json::Value; use unicode_width::UnicodeWidthStr; @@ -19,6 +19,29 @@ pub(super) fn headless(v: &Value) { /// The widest a table's column is. const MAX_COLUMN: usize = 48; +/// A table's lines, its columns' headings first: each column as wide as its widest cell (at most +/// [`MAX_COLUMN`]), two spaces apart. The CLI and the player draw them. +pub(crate) fn table(columns: &[String], rows: &[Vec]) -> Vec { + let cells: Vec> = rows.iter().map(|r| r.iter().map(cell).collect()).collect(); + let widths: Vec = (0..columns.len()) + .map(|i| { + let widest = cells.iter().filter_map(|r| r.get(i)).map(|c| c.width()).max().unwrap_or(0); + widest.max(columns[i].width()).min(MAX_COLUMN) + }) + .collect(); + let line = |row: &[String]| { + let cells = row.iter().zip(&widths).map(|(c, w)| output::fit(c, *w)).collect::>(); + cells.join(" ").trim_end().to_string() + }; + std::iter::once(line(columns)).chain(cells.iter().map(|r| line(r))).collect() +} + +/// Labelled values' lines, the labels aligned. The CLI and the player draw them. +pub(crate) fn pairs(pairs: &[Pair]) -> Vec { + let width = pairs.iter().map(|p| p.label.width()).max().unwrap_or(0); + pairs.iter().map(|p| format!("{} {}", output::fit(&p.label, width), cell(&p.value))).collect() +} + /// A view a plugin's command answered, drawn as the CLI's lists and tables are. pub(super) fn view(v: &View) { let (View::List { title, .. } | View::Table { title, .. } | View::Kv { title, .. } | View::Text { title, .. }) = v; @@ -35,26 +58,15 @@ pub(super) fn view(v: &View) { } } View::Table { columns, rows, .. } => { - let cells: Vec> = rows.iter().map(|r| r.iter().map(cell).collect()).collect(); - let widths: Vec = (0..columns.len()) - .map(|i| { - let widest = cells.iter().filter_map(|r| r.get(i)).map(|c| c.width()).max().unwrap_or(0); - widest.max(columns[i].width()).min(MAX_COLUMN) - }) - .collect(); - let line = |row: &[String]| { - let cells = row.iter().zip(&widths).map(|(c, w)| output::fit(c, *w)).collect::>(); - cells.join(" ").trim_end().to_string() - }; - say!("{}", dim(line(columns))); - for row in &cells { - say!("{}", line(row)); + let lines = table(columns, rows); + say!("{}", dim(&lines[0])); + for line in &lines[1..] { + say!("{line}"); } } - View::Kv { pairs, .. } => { - let width = pairs.iter().map(|p| p.label.width()).max().unwrap_or(0); - for p in pairs { - say!("{} {}", output::fit(&p.label, width), cell(&p.value)); + View::Kv { pairs: kv, .. } => { + for line in pairs(kv) { + say!("{line}"); } } View::Text { text, .. } => say!("{text}"), diff --git a/apps/locallibrary/src/plugins.rs b/apps/locallibrary/src/plugins.rs index 7ddbee30..c3c605a9 100644 --- a/apps/locallibrary/src/plugins.rs +++ b/apps/locallibrary/src/plugins.rs @@ -1,5 +1,5 @@ -//! The plugins locallibrary ships, and the host the CLI and the sidecar run them in (see -//! [`locallibrary::plugin`]). Only plugins linked into this binary run: each is started as +//! The plugins locallibrary ships, and the host the CLI, the player and the sidecar run them in +//! (see [`locallibrary::plugin`]). Only plugins linked into this binary run: each is started as //! `locallibrary plugin serve `, a process of its own speaking the plugin protocol over its //! stdin and stdout. A plugin whose id or contributions locallibrary couldn't tell from its own //! commands, the sidecar's methods or another plugin's is left out (see [`check`]): the command @@ -8,12 +8,13 @@ pub mod params; -use std::sync::{Arc, OnceLock}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use clap::{CommandFactory, ValueEnum}; use indexmap::IndexMap; use locallibrary::api::RpcFailure; -use locallibrary::plugin::{Launch, Options, PluginSpec, Plugins, Reads}; +use locallibrary::plugin::{Launch, Log, Options, PluginSpec, Plugins, Reads}; use locallibrary::progress::Ctx; use locallibrary::{Error, Result}; use loclib_plugin::{Capability, Manifest}; @@ -82,7 +83,8 @@ pub fn checked() -> Option<&'static Plugins> { LOADED.get()?.as_ref().ok().map(|l| &l.plugins) } -/// The host the CLI and the sidecar run plugins in. Plugins start when they're first used. +/// The host the CLI, the player and the sidecar run plugins in. Plugins start when they're first +/// used. pub fn host() -> Result<&'static Plugins> { Ok(&loaded()?.plugins) } @@ -102,7 +104,7 @@ fn load() -> Result { found.push((manifest, Serves::Test)); } let (mut specs, mut serves, mut left_out) = (Vec::new(), FxHashMap::default(), Vec::new()); - let program = std::env::current_exe().map_err(|e| Error::Invalid(format!("where locallibrary is: {e}")))?; + let program = exe().map_err(|e| Error::Invalid(format!("where locallibrary is: {e}")))?; let mut all = Vec::new(); for (manifest, how) in found { // The shell's completion request would have it answer that instead of serving. @@ -122,7 +124,41 @@ fn load() -> Result { } } } - Ok(Loaded { plugins: Plugins::new(specs, Arc::new(SidecarReads), Options::default())?, serves, left_out }) + let stderr = Options::default().log; + let log: Log = Arc::new(move |id, line| match LOG.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { + Some(log) => log(id, line), + None => stderr(id, line), + }); + let options = Options { log, ..Options::default() }; + Ok(Loaded { plugins: Plugins::new(specs, Arc::new(SidecarReads), options)?, serves, left_out }) +} + +/// Where plugins' logs go instead of stderr, while the player draws on the terminal. +static LOG: Mutex> = Mutex::new(None); + +/// Sends plugins' logs to `log` (the player's messages), or with `None` back to stderr. +#[cfg(feature = "tui")] +pub fn log_to(log: Option) { + *LOG.lock().unwrap_or_else(PoisonError::into_inner) = log; +} + +/// The locallibrary binary, which serves the plugins. +#[cfg(not(test))] +fn exe() -> std::io::Result { + std::env::current_exe() +} + +/// A unit test's binary is the test harness, which can't serve a plugin: the locallibrary binary +/// cargo builds beside it for the integration tests does. +#[cfg(test)] +fn exe() -> std::io::Result { + let deps = std::env::current_exe()?; + let dir = deps.parent().and_then(|d| d.parent()).ok_or_else(|| std::io::Error::other("no target folder"))?; + let exe = dir.join(format!("locallibrary{}", std::env::consts::EXE_SUFFIX)); + match exe.is_file() { + true => Ok(exe), + false => Err(std::io::Error::other(format!("{} isn't built (cargo build)", exe.display()))), + } } /// A name a plugin adds where it must be its alone (the command line, `export --format`): whose diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs index e5ecaf56..83b3a5ad 100644 --- a/apps/locallibrary/src/tui/app.rs +++ b/apps/locallibrary/src/tui/app.rs @@ -710,6 +710,8 @@ pub enum Msg { Opened(u64, Result), /// Something background work has to say (a report written, say). Note(String, Level), + /// A line of a plugin's log, for the messages (`!`) alone. + Log(String), /// The playing track's analysis, artwork and matches, for the play with this id. TrackInfo(u64, Box), Details(Box
), @@ -1010,7 +1012,7 @@ pub struct App { /// Said on the terminal after the screen is put back (how a write that ended the run went). pub exit_message: Option, /// Drives opened by path that aren't mounted volumes (a folder holding an export). - extra_roots: Vec, + pub(super) extra_roots: Vec, /// Look at no drives but those it's given: the mounted ones aren't listed, nor their status /// read (tests, which mustn't touch the machine's drives). offline: bool, @@ -1369,10 +1371,7 @@ impl App { /// Say something in the footer (and the log). pub fn toast(&mut self, msg: impl Into, level: Level) { let msg = msg.into(); - if self.log.len() >= LOG { - self.log.pop_front(); - } - self.log.push_back((msg.clone(), level, Instant::now())); + self.remember(msg.clone(), level); // An error that wasn't dismissed stays on screen, above what came after it. if let Some((m, Level::Error, _)) = self.toast.take() && m != msg @@ -1382,6 +1381,22 @@ impl App { self.toast = Some((msg, level, Instant::now())); } + /// Keep a message in the log (`!`). + fn remember(&mut self, msg: String, level: Level) { + if self.log.len() >= LOG { + self.log.pop_front(); + } + self.log.push_back((msg, level, Instant::now())); + } + + /// Where plugins' logs go while the player runs: each line, with the plugin's id, to the log. + pub(super) fn plugin_log(&self) -> locallibrary::plugin::Log { + let tx = self.sender(); + Arc::new(move |id, line| { + let _ = tx.send(Msg::Log(format!("{id}: {line}"))); + }) + } + /// Esc with an error on screen: put it away. Returns whether there was one. fn dismiss_errors(&mut self) -> bool { let error = self.toast.as_ref().is_some_and(|(_, level, _)| *level == Level::Error); @@ -1751,6 +1766,7 @@ impl App { } Msg::Opened(id, r) => self.opened(id, r), Msg::Note(msg, level) => self.toast(msg, level), + Msg::Log(line) => self.remember(line, Level::Info), Msg::TrackInfo(id, info) => { let upcoming = self.upcoming.as_mut().map(|u| &mut u.now); if let Some(now) = self.now.as_mut().into_iter().chain(upcoming).find(|n| n.id == id) { @@ -3190,6 +3206,7 @@ impl App { Act::SmartNew => self.smart_from_search(), Act::ExportList => self.export_list(), Act::ExportXml => self.export_xml(), + Act::Plugin(plugin, name) => self.plugin_act(plugin, name, None), Act::Details => { self.show_details = !self.show_details; if !self.show_details && self.focus == Focus::Details { @@ -3552,11 +3569,10 @@ impl App { /// Clicks while a dialog is open: menu items can be clicked; a click elsewhere closes it. fn on_modal_mouse(&mut self, m: MouseEvent) { - if let Some(Modal::Pick(_)) = &self.modal { - if let Some(Modal::Pick(p)) = self.modal.take() { - self.on_picker_mouse(p, m); - } - return; + match self.modal.take() { + Some(Modal::Pick(p)) => return self.on_picker_mouse(p, m), + Some(Modal::Form(f)) => return self.on_form_mouse(f, m), + other => self.modal = other, } if m.kind != MouseEventKind::Down(MouseButton::Left) { return; diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index 59e75558..b4e5c1ac 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -474,3 +474,22 @@ impl locallibrary::op::AnyPlanned for Plan { pub fn hold_volume_polls(app: &mut App) { app.last_volume_poll = Instant::now() + Duration::from_secs(3600); } + +/// loclib-plugin's echo plugin, which the plugin host's test plugin serves: its export format +/// renamed (`m3u` is locallibrary's), and its `echo` command taking a param of each kind the +/// player's form fills in, besides the test plugin's `log` and `context`. +const ECHO: &str = include_str!("../../../../../crates/loclib-plugin/examples/echo/locallibrary-plugin.toml"); + +/// The plugins with the echo plugin among them, for this test: its manifest written in `dir` and +/// named by `LOCALLIBRARY_TEST_PLUGINS`, which the host reads once, as it's first used. Call it +/// before anything opens the palette or a menu. +pub fn echo_plugin(dir: &Path) { + let params = r#"params = { type = "object", required = ["name"], properties = { name = { type = "string", minLength = 2, description = "Who to greet" }, times = { type = "integer" }, loud = { type = "boolean" }, mood = { type = "string", enum = ["calm", "wild"] }, file = { type = "string", x-path = true }, tags = { type = "array", items = { type = "string" } }, log = { type = "string" }, context = { type = "boolean" } } }"#; + let manifest = + ECHO.replace("format = \"m3u\"", "format = \"echo-m3u\"").replacen("params = { type = \"object\" }", params, 1); + let file = dir.join("echo.toml"); + std::fs::write(&file, manifest).unwrap(); + // SAFETY: nextest runs each test in a process of its own, and this runs before the test + // starts a thread. + unsafe { std::env::set_var("LOCALLIBRARY_TEST_PLUGINS", &file) }; +} diff --git a/apps/locallibrary/src/tui/drives.rs b/apps/locallibrary/src/tui/drives.rs index adb5ddc3..a5affba0 100644 --- a/apps/locallibrary/src/tui/drives.rs +++ b/apps/locallibrary/src/tui/drives.rs @@ -57,6 +57,10 @@ pub enum Tone { Problem, Text, Faint, + /// A table's headings, and its rows: cut at the panel's edge rather than wrapped, so the + /// columns stay aligned. + Columns, + Row, } /// The lines of a check's report: how it went, then each drive's items, problems and warnings @@ -318,13 +322,14 @@ impl App { Act::Replay => self.replay(root), Act::Undo => self.undo_on(root), Act::Eject => self.eject(vec![root]), + Act::Plugin(plugin, name) => self.plugin_act(plugin, name, Some(root)), act => self.perform(act, None), } } - /// `e` in the Drives view: what can be done to the selected drive. What's chosen is done to - /// that drive (by its identity: see [`DriveKey`]), even if another is selected by then, or - /// refused once it's been unplugged. + /// `e` in the Drives view: what can be done to the selected drive (plugins' commands last). + /// What's chosen is done to that drive (by its identity: see [`DriveKey`]), even if another + /// is selected by then, or refused once it's been unplugged. pub(super) fn drive_menu(&mut self) { match self.header() { Some(DriveRow::Library(id)) => return self.library_menu(id), @@ -351,7 +356,7 @@ impl App { item(Act::Undo, undo_key, &None), item(Act::Eject, "", &None), ]; - let (acts, items): (Vec, Vec) = items.into_iter().unzip(); + let (acts, items): (Vec, Vec) = items.into_iter().chain(self.plugin_commands(Some(&root))).unzip(); let drive = self.drive_at(root); self.modal = Some(Modal::Pick(Picker::new(format!("Drive · {name}"), items, Then::Drive { drive, name, acts }))); diff --git a/apps/locallibrary/src/tui/edits.rs b/apps/locallibrary/src/tui/edits.rs index 3fc68ebe..acbe0d74 100644 --- a/apps/locallibrary/src/tui/edits.rs +++ b/apps/locallibrary/src/tui/edits.rs @@ -361,8 +361,10 @@ pub enum Modal { Ask(String, Box), /// A list to choose from by typing: the command palette, or an answer an action needs. Pick(super::palette::Picker), - /// Results to read (a check's). + /// Results to read (a check's, a plugin's command's). Report(Report), + /// A plugin's params to fill in. + Form(super::form::Form), } /// Choosing the playlist to add tracks to, in the sidebar tree. @@ -655,6 +657,7 @@ impl App { } }, Modal::Pick(p) => self.on_picker_key(p, key), + Modal::Form(f) => self.on_form_key(f, key), Modal::Report(mut r) => { r.scroll = match key.code { KeyCode::Up | KeyCode::Char('k') => r.scroll.saturating_sub(1), diff --git a/apps/locallibrary/src/tui/form.rs b/apps/locallibrary/src/tui/form.rs new file mode 100644 index 00000000..6d93ece6 --- /dev/null +++ b/apps/locallibrary/src/tui/form.rs @@ -0,0 +1,175 @@ +//! A form for a plugin's params: a field for each property of their JSON Schema, each typed as +//! the command line takes it (see [`crate::plugins::params`]), and checked against the schema +//! before anything runs. A switch and a choice of a few values are picked rather than typed. + +use loclib_plugin::{check_params, is_path}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Position; +use serde_json::{Map, Value}; + +use super::app::{App, edit_line}; +use super::edits::Modal; +use super::plugins::Run; +use crate::plugins::params::{self, Takes, cell, properties, takes}; + +pub struct Form { + pub title: String, + pub fields: Vec, + pub selected: usize, + /// Why what's filled in won't do. + pub error: Option, + /// What it's filled in for. + pub run: Run, +} + +pub struct Field { + /// The param's name. + pub name: String, + /// Its JSON Schema. + pub schema: Value, + pub required: bool, + /// What's typed or picked: `true` or `false` for a switch, an enum's choice, or "" for none. + pub text: String, +} + +impl Field { + /// What it's called: its name in words (`skip analysis`). + pub fn label(&self) -> String { + self.name.replace('_', " ") + } + + /// A boolean: on or off. + pub fn is_switch(&self) -> bool { + takes(&self.schema) == Some(Takes::Switch) + } + + /// What it's picked from instead of typed: a switch's values, or an enum's choices (and none, + /// when it can be left out). + pub fn choices(&self) -> Vec { + if self.is_switch() { + return vec!["false".into(), "true".into()]; + } + let Some(choices) = self.schema["enum"].as_array() else { return Vec::new() }; + let none = (!self.required).then(String::new); + none.into_iter().chain(choices.iter().map(cell)).collect() + } + + /// A switch's value, or an enum's choice, `by` places on (back when negative). + fn pick(&mut self, by: isize) { + let choices = self.choices(); + let at = choices.iter().position(|c| *c == self.text).unwrap_or(0) as isize; + if let Some(next) = choices.get((at + by).rem_euclid(choices.len().max(1) as isize) as usize) { + self.text = next.clone(); + } + } +} + +impl Form { + /// The form for `run`'s params, each field filled in with its default (none when its command + /// takes none). + pub fn new(run: Run) -> Form { + let schema = run.command.params.as_ref(); + let required: Vec<&str> = + schema.and_then(|s| s["required"].as_array()).into_iter().flatten().filter_map(Value::as_str).collect(); + let fields: Vec = properties(schema) + .map(|(name, schema)| { + let required = required.contains(&name.as_str()); + let mut field = Field { name: name.clone(), schema: schema.clone(), required, text: String::new() }; + field.text = match schema.get("default") { + Some(default) => cell(default), + None => field.choices().first().cloned().unwrap_or_default(), + }; + field + }) + .collect(); + Form { title: run.command.title.clone(), fields, selected: 0, error: None, run } + } + + /// The params filled in, or which field won't do and why: one that's needed is empty, one + /// isn't of its type, or doesn't fit the schema. Lists and pairs are typed with commas + /// between them, and paths may start with `~`. + pub fn params(&self) -> Result { + let mut given = Map::new(); + for (i, f) in self.fields.iter().enumerate() { + let text = f.text.trim(); + if text.is_empty() { + if f.required { + return Err((i, format!("{} is needed", f.label()))); + } + continue; + } + let raws: Vec = match takes(&f.schema) { + Some(Takes::Many(_) | Takes::Pairs(_)) => { + text.split(',').map(str::trim).filter(|r| !r.is_empty()).map(String::from).collect() + } + _ => vec![text.to_string()], + }; + let raws: Vec = match is_path(&f.schema) { + true => raws.iter().map(|r| super::mix::typed_path(r).display().to_string()).collect(), + false => raws, + }; + let raws: Vec<&str> = raws.iter().map(String::as_str).collect(); + given.insert(f.name.clone(), params::value(&f.schema, &raws, &f.label()).map_err(|e| (i, e))?); + } + let given = Value::Object(given); + check_params(self.run.command.params.as_ref(), &given).map_err(|e| { + // The field the schema's complaint is about (`/into`), named as it's shown. + let path = e.data.and_then(|d| d.fields.get("path").and_then(Value::as_str).map(String::from)); + let at = path.as_deref().and_then(|p| p.trim_start_matches('/').split('/').next()); + let field = at.and_then(|name| self.fields.iter().position(|f| f.name == name)); + // Said as the form's own: without the protocol's "invalid params: " and the pointer. + let said = e.message.strip_prefix("invalid params: ").unwrap_or(&e.message); + let said = path.as_deref().and_then(|p| said.strip_prefix(&format!("{p}: "))).unwrap_or(said); + match field { + Some(i) => (i, format!("{}: {said}", self.fields[i].label())), + None => (self.selected, said.to_string()), + } + })?; + Ok(given) + } +} + +impl App { + /// Keys while a form is open: the arrows (or tab) move between fields, typing fills one in, + /// space and ←→ pick a switch's or a choice's value, enter runs it and esc closes it. + pub(super) fn on_form_key(&mut self, mut f: Form, key: KeyEvent) { + let n = f.fields.len(); + let field = &mut f.fields[f.selected]; + let picked = !field.choices().is_empty(); + match key.code { + KeyCode::Esc => return, + KeyCode::Enter => match f.params() { + Ok(params) => return self.run_plugin(f.run, params), + Err((i, why)) => (f.selected, f.error) = (i, Some(why)), + }, + KeyCode::Down | KeyCode::Tab => f.selected = (f.selected + 1) % n, + KeyCode::Up | KeyCode::BackTab => f.selected = (f.selected + n - 1) % n, + KeyCode::Right | KeyCode::Char(' ') if picked => field.pick(1), + KeyCode::Left if picked => field.pick(-1), + _ if !picked && edit_line(&mut field.text, key) => f.error = None, + _ => {} + } + self.modal = Some(Modal::Form(f)); + } + + /// Clicks while a form is open: a field is chosen; a click outside closes the form. + pub(super) fn on_form_mouse(&mut self, mut f: Form, m: MouseEvent) { + let r = self.areas.menu; + if m.kind != MouseEventKind::Down(MouseButton::Left) { + self.modal = Some(Modal::Form(f)); + return; + } + if !r.contains(Position::new(m.column, m.row)) { + return; + } + // The fields are the first rows, under the border. + let row = m.row.saturating_sub(r.y + 1) as usize; + if m.row > r.y && row < f.fields.len() { + f.selected = row; + } + self.modal = Some(Modal::Form(f)); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/locallibrary/src/tui/form/tests.rs b/apps/locallibrary/src/tui/form/tests.rs new file mode 100644 index 00000000..0035cd1d --- /dev/null +++ b/apps/locallibrary/src/tui/form/tests.rs @@ -0,0 +1,161 @@ +use loclib_plugin::{Command, Context, Manifest}; +use serde_json::json; + +use super::super::app::View; +use super::super::app::testing::{LEFT, blank, mouse, press, spot}; +use super::*; + +/// A form for a command whose params are `params` (a JSON Schema, as TOML). +fn form(params: &str) -> Form { + let manifest = format!( + "id = \"t\"\nname = \"T\"\nversion = \"0.1.0\"\nprotocol = \"^0.1\"\n\ + [[contributes.commands]]\nname = \"c\"\ntitle = \"Do it\"\nmode = \"headless\"\n{params}" + ); + let manifest = Manifest::parse(&manifest).unwrap(); + let command: &'static Command = Box::leak(Box::new(manifest.contributes.commands[0].clone())); + Form::new(Run { plugin: "t", command, context: Context::default() }) +} + +fn filled(f: &mut Form, texts: &[&str]) -> Result { + for (field, text) in f.fields.iter_mut().zip(texts) { + field.text = text.to_string(); + } + f.params() +} + +/// No params, or none named, is a form of no fields: the command runs without one. +#[test] +fn a_command_without_params_has_no_form() { + assert!(form("").fields.is_empty()); + assert!(form("params = { type = \"object\" }").fields.is_empty()); +} + +/// Fields start at their defaults, a choice that can be left out at none and a switch at off. +#[test] +fn fields_start_at_their_defaults() { + let f = form( + r#"params = { type = "object", required = ["kind"], properties = { n = { type = "integer", default = 4 }, kind = { type = "string", enum = ["a", "b"] }, maybe = { type = "string", enum = ["a", "b"] }, on = { type = "boolean" }, skip_it = { type = "boolean", default = true } } }"#, + ); + let texts: Vec<&str> = f.fields.iter().map(|x| x.text.as_str()).collect(); + assert_eq!(texts, ["4", "a", "", "false", "true"]); + assert_eq!(f.fields[4].label(), "skip it"); + assert_eq!(f.fields[2].choices(), ["", "a", "b"]); + assert_eq!(f.fields[1].choices(), ["a", "b"], "a choice that's needed can't be none"); + assert!(f.fields[0].choices().is_empty()); +} + +/// What's typed is read as the command line reads it: numbers, lists with commas between them, +/// pairs, paths made absolute (`~` for home); what's left empty is left out. +#[test] +fn what_is_typed_becomes_the_params() { + let mut f = form( + r#"params = { type = "object", properties = { bpm = { type = "number" }, ids = { type = "array", items = { type = "integer" } }, pick = { type = "object", additionalProperties = { type = "integer" } }, file = { type = "string", x-path = true }, note = { type = "string" } } }"#, + ); + let home = std::env::var("HOME").unwrap_or_default(); + let params = filled(&mut f, &["120.5", "1, #2,3,", "1=4, 2=5", "~/list.m3u", ""]).unwrap(); + assert_eq!(params["bpm"], json!(120.5)); + assert_eq!(params["ids"], json!([1, 2, 3])); + assert_eq!(params["pick"], json!({ "1": 4, "2": 5 })); + assert_eq!(params["file"], json!(format!("{home}/list.m3u"))); + assert!(params.get("note").is_none()); + assert_eq!(filled(&mut f, &["fast"]).unwrap_err(), (0, "bpm takes a number, not \"fast\"".into())); + assert_eq!(filled(&mut f, &["", "1", "4"]).unwrap_err(), (2, "pick takes KEY=VALUE, not \"4\"".into())); +} + +/// A click on a field selects it, the wheel leaves it be, and a click outside closes the form; +/// up and shift-tab go round to the last field, and ← picks the choice before. +#[test] +fn fields_are_clicked_and_gone_round() { + let (mut app, _dir) = blank(View::Library); + let f = form( + r#"params = { type = "object", properties = { first = { type = "string" }, second = { type = "string", enum = ["a", "b"] } } }"#, + ); + app.modal = Some(Modal::Form(f)); + let selected = |app: &App| match &app.modal { + Some(Modal::Form(f)) => (f.selected, f.fields[1].text.clone()), + _ => panic!("closed"), + }; + let second = spot(&mut app, "second"); + mouse(&mut app, ratatui::crossterm::event::MouseEventKind::ScrollDown, second); + assert_eq!(selected(&app).0, 0); + mouse(&mut app, LEFT, second); + assert_eq!(selected(&app), (1, String::new())); + app.on_key(KeyEvent::new(KeyCode::Left, ratatui::crossterm::event::KeyModifiers::NONE)); + assert_eq!(selected(&app), (1, "b".into()), "round to the last choice"); + app.on_key(KeyEvent::new(KeyCode::BackTab, ratatui::crossterm::event::KeyModifiers::SHIFT)); + app.on_key(KeyEvent::new(KeyCode::Up, ratatui::crossterm::event::KeyModifiers::NONE)); + assert_eq!(selected(&app).0, 1, "round to the last field"); + press(&mut app, " "); + assert_eq!(selected(&app).1, "", "space picks the next"); + mouse(&mut app, LEFT, (0, 0)); + assert!(app.modal.is_none()); +} + +/// What the schema refuses is said on the field it's about. +#[test] +fn the_schema_is_checked_on_the_field() { + let mut f = form( + r#"params = { type = "object", properties = { a = { type = "string" }, n = { type = "integer", minimum = 1 } } }"#, + ); + assert_eq!(filled(&mut f, &["x", "0"]).unwrap_err(), (1, "n: 0 is less than the minimum of 1".into())); + assert_eq!(filled(&mut f, &["x", "1"]).unwrap(), json!({ "a": "x", "n": 1 })); +} + +/// A whole number typed in is a param exactly when it reads as one; left empty, the form is +/// short of it only when it's needed. +#[hegel::test(test_cases = 200)] +fn a_whole_number_is_taken_when_it_reads_as_one(tc: hegel::TestCase) { + use hegel::generators as gs; + let required = tc.draw(gs::booleans()); + let schema = format!( + r#"params = {{ type = "object", required = [{}], properties = {{ n = {{ type = "integer" }} }} }}"#, + if required { "\"n\"" } else { "" } + ); + let mut f = form(&schema); + let typed = tc.draw(gs::sampled_from(&["", " ", "7", " 42 ", "#9", "-3", "4.5", "x", "1e3", "99999999999"][..])); + let read = typed.trim().strip_prefix('#').unwrap_or(typed.trim()).parse::(); + match (filled(&mut f, &[typed]), typed.trim().is_empty()) { + (Ok(p), true) => assert!(!required && p == json!({}), "{typed:?}"), + (Err((0, why)), true) => assert!(required && why == "n is needed", "{typed:?}: {why}"), + (Ok(p), false) => assert_eq!(p["n"], json!(read.unwrap()), "{typed:?}"), + (Err((0, why)), false) => assert!(read.is_err(), "{typed:?}: {why}"), + (Err(e), _) => panic!("{e:?}"), + } +} + +/// Keys in any order keep the form whole: a field is always selected, a switch is on or off and +/// a choice is one of its choices, and what's typed goes to the field selected alone. +#[hegel::test(test_cases = 200)] +fn keys_keep_the_form_whole(tc: hegel::TestCase) { + use hegel::generators::{self as gs, Generator}; + let (mut app, _dir) = blank(View::Library); + let f = form( + r#"params = { type = "object", properties = { a = { type = "string" }, on = { type = "boolean" }, pick = { type = "string", enum = ["x", "y"] }, b = { type = "string" } } }"#, + ); + app.modal = Some(Modal::Form(f)); + let moves = [KeyCode::Up, KeyCode::Down, KeyCode::Tab, KeyCode::BackTab, KeyCode::Left, KeyCode::Right]; + // What the two text fields should hold. + let mut typed = [String::new(), String::new()]; + for _ in 0..tc.draw(gs::integers::().max_value(30)) { + let Some(Modal::Form(f)) = &app.modal else { panic!("closed") }; + let text = [0, 3].iter().position(|&i| i == f.selected); + match tc.draw(gs::booleans()) { + true => { + let c = tc.draw(gs::sampled_from(&[' ', 'k', 'j', 'q', '7'][..])); + press(&mut app, &c.to_string()); + if let Some(t) = text { + typed[t].push(c); + } + } + false => app.on_key(KeyEvent::new( + tc.draw(gs::sampled_from(&moves[..]).print_as_debug()), + ratatui::crossterm::event::KeyModifiers::NONE, + )), + } + let Some(Modal::Form(f)) = &app.modal else { panic!("closed") }; + assert!(f.selected < 4); + assert!(["true", "false"].contains(&f.fields[1].text.as_str()), "{:?}", f.fields[1].text); + assert!(["", "x", "y"].contains(&f.fields[2].text.as_str()), "{:?}", f.fields[2].text); + assert_eq!([f.fields[0].text.as_str(), f.fields[3].text.as_str()], [typed[0].as_str(), typed[1].as_str()]); + } +} diff --git a/apps/locallibrary/src/tui/keymap.rs b/apps/locallibrary/src/tui/keymap.rs index 3b65cd67..641db12f 100644 --- a/apps/locallibrary/src/tui/keymap.rs +++ b/apps/locallibrary/src/tui/keymap.rs @@ -180,6 +180,9 @@ pub enum Act { MixZoom, MixExport, MixReport, + /// A plugin's command, by the plugin's id and its name (from the palette and the drive + /// menu, never a key). + Plugin(&'static str, &'static str), } /// The help's sections. @@ -368,7 +371,7 @@ pub static KEYMAP: &[Binding] = &[ // The Drives and Snapshots views // ----------------------------------------------------------------------------------------- note(S::Drives, "enter", "open the drive in the library (a library: its newest clone)", G::Drives), - key(S::Drives, &[K::Ch('e')], "e", A::DriveActions, "actions for the drive", "what can be done to the selected drive: save a snapshot, check, sync, pack, bring back a playlist, replay edits, undo, eject; to a library's clones: sync, check, eject (each shows its plan and asks first)", G::Drives), + key(S::Drives, &[K::Ch('e')], "e", A::DriveActions, "actions for the drive", "what can be done to the selected drive: save a snapshot, check, sync, pack, bring back a playlist, replay edits, undo, eject, plugins' commands; to a library's clones: sync, check, eject (each shows its plan and asks first)", G::Drives), tip(S::Drives, "↑↓", "select", 4), tip(S::Drives, "enter", "browse", 9), tip(S::Drives, "e", "actions", 8), @@ -494,7 +497,7 @@ pub static KEYMAP: &[Binding] = &[ key(S::Global, &[], "4", A::ShowView(View::Profile), "the DJ profile", "", G::Player), key(S::Global, &[], "5", A::ShowView(View::Mixes), "recorded mixes", "", G::Player), key(S::Global, &[K::Ch('R'), K::Code(C::F(5))], "R F5", A::Refresh, "refresh: read the drive and the view again", "read again: the drive, the drives' status, the snapshots, the profile, the recordings", G::Player), - key(S::Global, &[K::Ch(':')], ":", A::Palette, "", "commands: every action by name (type a few of its letters), and the commands with no key", G::Player).hint("commands", 6), + key(S::Global, &[K::Ch(':')], ":", A::Palette, "", "commands: every action by name (type a few of its letters), the commands with no key, and plugins' commands", G::Player).hint("commands", 6), key(S::Global, &[K::Ch('?')], "?", A::Help, "the keys", "this help", G::Player).hint("help", 5), key(S::Global, &[K::Ch('!')], "!", A::Messages, "messages since starting", "messages: everything the footer said since starting", G::Player), // Commands with no key of their own, for the drive open in the library (the selected one in diff --git a/apps/locallibrary/src/tui/mod.rs b/apps/locallibrary/src/tui/mod.rs index ff90bb98..55b68828 100644 --- a/apps/locallibrary/src/tui/mod.rs +++ b/apps/locallibrary/src/tui/mod.rs @@ -12,11 +12,13 @@ mod crate_list; mod deck; mod drives; mod edits; +mod form; mod keymap; mod keys; mod mix; mod mytags; mod palette; +mod plugins; mod profile; #[cfg(test)] mod props; @@ -45,6 +47,8 @@ pub fn run(store_root: PathBuf, drive: Option) -> Result<()> { on_panic(); // Open the audio device before taking over the screen: some backends print while starting. let mut app = App::new(store_root, drive); + // Plugins' logs would draw over the screen: they go to the messages (`!`) while it runs. + crate::plugins::log_to(Some(app.plugin_log())); // (Not `ratatui::init`, whose panic hook would put the screen back for a worker's panic.) enable_raw_mode()?; execute!(stdout(), EnterAlternateScreen)?; @@ -54,6 +58,7 @@ pub fn run(store_root: PathBuf, drive: Option) -> Result<()> { let result = event_loop(&mut terminal, &mut app); let _ = execute!(stdout(), DisableMouseCapture); ratatui::restore(); + crate::plugins::log_to(None); SIGNAL.store(IDLE, Ordering::Relaxed); // The loop also ends when the terminal goes away mid-write: the write still finishes. app.finish_write(); diff --git a/apps/locallibrary/src/tui/palette.rs b/apps/locallibrary/src/tui/palette.rs index 295bf76c..a005ca47 100644 --- a/apps/locallibrary/src/tui/palette.rs +++ b/apps/locallibrary/src/tui/palette.rs @@ -182,9 +182,10 @@ pub fn commands(view: View) -> Vec<(Act, Item)> { } impl App { - /// `:`: every action by name. + /// `:`: every action by name, then plugins' commands. pub(super) fn open_palette(&mut self) { - let (acts, items): (Vec, Vec) = commands(self.view).into_iter().unzip(); + let (acts, items): (Vec, Vec) = + commands(self.view).into_iter().chain(self.plugin_commands(self.action_drive().as_deref())).unzip(); let mut p = Picker::new("Commands", items, Then::Act(acts)); p.empty = "no command has those letters"; self.modal = Some(Modal::Pick(p)); diff --git a/apps/locallibrary/src/tui/plugins.rs b/apps/locallibrary/src/tui/plugins.rs new file mode 100644 index 00000000..3bdeee0c --- /dev/null +++ b/apps/locallibrary/src/tui/plugins.rs @@ -0,0 +1,159 @@ +//! Plugins in the player: their commands in the palette and the drive menu, a form for their +//! params (see [`super::form`]), run in the background as any task is (esc stops one), and what +//! they answer in a panel. Nothing here tells one plugin from another: it's all drawn from their +//! manifests, through the host the command line uses (see [`crate::plugins`]). + +use std::path::{Path, PathBuf}; + +use locallibrary::Store; +use locallibrary::plugin::CommandOutput; +use loclib_plugin::{Capability, Command, Context, View}; +use serde_json::Value; + +use super::app::{App, Level, View as Screen}; +use super::drives::{Report, Tone}; +use super::edits::Modal; +use super::form::Form; +use super::keymap::Act; +use super::palette::Item; +use crate::plugin_cmd::view::{pairs, table}; + +/// A plugin's command, and where it runs: the drive, and what's selected there. +pub struct Run { + pub plugin: &'static str, + pub command: &'static Command, + pub context: Context, +} + +/// Whether a plugin with `capability` reads a drive: it names one when it calls back. +fn reads_drive(capability: &Capability) -> bool { + use Capability::*; + matches!( + capability, + LibraryRead + | AnalysisRead + | ArtworkRead + | DriveInfo + | HealthRead + | SettingsRead + | PlaysRead + | SetsRead + | CollectionRead + ) +} + +/// A view a command answered, as a panel's lines: a list's items (each detail under its label), +/// a table's rows under its headings, labelled values aligned, or text. +pub fn view_lines(v: &View) -> Vec<(Tone, String)> { + let mut lines = Vec::new(); + match v { + View::List { items, .. } => { + for i in items { + lines.push((Tone::Text, i.label.clone())); + lines.extend(i.detail.as_ref().map(|d| (Tone::Faint, format!(" {d}")))); + } + } + View::Table { columns, rows, .. } => { + let mut table = table(columns, rows).into_iter(); + lines.extend(table.next().map(|headings| (Tone::Columns, headings))); + lines.extend(table.map(|row| (Tone::Row, row))); + if rows.is_empty() { + lines.push((Tone::Faint, "nothing to show".into())); + } + } + View::Kv { pairs: kv, .. } => lines.extend(pairs(kv).into_iter().map(|p| (Tone::Text, p))), + View::Text { text, .. } => lines.extend(text.lines().map(|l| (Tone::Text, l.to_string()))), + } + if lines.is_empty() { + lines.push((Tone::Faint, "nothing to show".into())); + } + lines +} + +impl App { + /// Every plugin's commands, as the palette and the drive menu list them for the drive at + /// `root`: by title, with the plugin's name beside each. Those of plugins that read a drive + /// can't run without one, nor on a drive opened as a folder, which a plugin can't name. + pub(super) fn plugin_commands(&self, root: Option<&Path>) -> Vec<(Act, Item)> { + let Ok(plugins) = crate::plugins::host() else { return Vec::new() }; + let why = match root { + None => Some("open a drive first"), + Some(root) if self.extra_roots.iter().any(|r| r == root) => Some("only on a plugged-in drive"), + Some(_) => None, + }; + let all = plugins.manifests().flat_map(|m| m.contributes.commands.iter().map(move |c| (m, c))); + all.map(|(m, c)| { + let reads = m.capabilities.iter().any(reads_drive); + let unavailable = why.filter(|_| reads).map(String::from); + (Act::Plugin(&m.id, &c.name), Item { unavailable, ..Item::new(&c.title, &m.name) }) + }) + .collect() + } + + /// Where the user is, as a plugin's hook takes it: the drive actions work on, and in the + /// library the playlist shown and the tracks marked or selected (by id). + fn context(&self) -> Context { + let drive = self.action_drive().map(|r| r.display().to_string()); + let target = (self.view == Screen::Library).then(|| self.target().zip(self.loaded.as_ref())).flatten(); + let Some((t, l)) = target else { return Context { drive, ..Context::default() } }; + let tracks = t.tracks.iter().filter_map(|p| l.index_of(p)).map(|i| l.library.tracks[i].id).collect(); + Context { drive, playlist: t.list, tracks, ..Context::default() } + } + + /// A plugin's command, from the palette (where the user is) or the drive menu (on the drive + /// at `root`): its form first when it takes params. + pub(super) fn plugin_act(&mut self, plugin: &'static str, name: &'static str, root: Option) { + let manifest = crate::plugins::host().and_then(|p| p.manifest(plugin)); + let Some(command) = manifest.ok().and_then(|m| m.contributes.commands.iter().find(|c| c.name == name)) else { + return; + }; + let context = match root { + Some(root) => Context { drive: Some(root.display().to_string()), ..Context::default() }, + None => self.context(), + }; + let form = Form::new(Run { plugin, command, context }); + match form.fields.is_empty() { + true => self.run_plugin(form.run, Value::Null), + false => self.modal = Some(Modal::Form(form)), + } + } + + /// Run a plugin's command with `params` in the background; what it answers shows once it's + /// done. + pub(super) fn run_plugin(&mut self, run: Run, params: Value) { + let Ok(plugins) = crate::plugins::host() else { return }; + let Run { plugin, command, context } = run; + let store_root = self.store_root.clone(); + let title = command.title.clone(); + self.task(title.clone(), move |ctx| { + let store = Store::open(&store_root).ok(); + let answer = plugins.run_command(plugin, &command.name, params, context, store.as_ref(), ctx)?; + Ok(Box::new(move |app: &mut App| app.show_answer(title, answer))) + }); + } + + /// What a command answered: nothing or a line of text as a message, a view in a panel + /// (titled as it says, else as the command is), and anything else in a panel as it is. + pub(super) fn show_answer(&mut self, title: String, answer: CommandOutput) { + let (title, lines) = match answer { + CommandOutput::Headless(Value::Null) => return self.toast(format!("{title}: done"), Level::Info), + CommandOutput::Headless(Value::String(s)) if !s.contains('\n') => return self.toast(s, Level::Info), + CommandOutput::Headless(Value::String(s)) => (title, s.lines().map(|l| (Tone::Text, l.into())).collect()), + CommandOutput::Headless(v) => { + let text = serde_json::to_string_pretty(&v).unwrap_or_default(); + (title, text.lines().map(|l| (Tone::Text, l.into())).collect()) + } + CommandOutput::View(v) => { + let (View::List { title: own, .. } + | View::Table { title: own, .. } + | View::Kv { title: own, .. } + | View::Text { title: own, .. }) = &v; + (own.clone().unwrap_or(title), view_lines(&v)) + } + }; + self.modal = Some(Modal::Report(Report { title, lines, scroll: 0 })); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/locallibrary/src/tui/plugins/tests.rs b/apps/locallibrary/src/tui/plugins/tests.rs new file mode 100644 index 00000000..938f4227 --- /dev/null +++ b/apps/locallibrary/src/tui/plugins/tests.rs @@ -0,0 +1,218 @@ +use loclib_plugin::{ListItem, Pair}; +use ratatui::crossterm::event::KeyCode; +use serde_json::json; + +use super::super::app::testing::{app_on, blank, demo_drive, echo_plugin, key, last_message, press, wait}; +use super::super::palette::Picker; +use super::super::ui::screen; +use super::*; + +/// The list open now. +fn picker(app: &App) -> &Picker { + match &app.modal { + Some(Modal::Pick(p)) => p, + _ => panic!("no list open: {}", last_message(app)), + } +} + +/// The item labelled `label` in the list open, and why it can't be chosen. +fn entry<'a>(app: &'a App, label: &str) -> (&'a Item, Option<&'a str>) { + let item = picker(app).items.iter().find(|i| i.label == label).unwrap_or_else(|| panic!("no {label:?}")); + (item, item.unavailable.as_deref()) +} + +/// The panel's lines, as one text. +fn panel(app: &App) -> String { + match &app.modal { + Some(Modal::Report(r)) => r.lines.iter().map(|(_, l)| l.as_str()).collect(), + _ => panic!("no panel: {}", last_message(app)), + } +} + +/// Every plugin's commands are in the palette, by title with the plugin's name, after the +/// player's own; those of a plugin reading a drive say why they can't run without one, or on a +/// drive opened as a folder (which a plugin can't name). +#[test] +fn plugins_commands_are_in_the_palette() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let (mut app, _store) = blank(Screen::Library); + press(&mut app, ":"); + let (item, why) = entry(&app, "Answer the params"); + assert_eq!((item.detail.as_str(), why), ("Echo", Some("open a drive first"))); + assert_eq!(entry(&app, "Find a tracklist's tracks").0.detail, "Tracklist import"); + let p = picker(&app); + let echo = p.items.iter().position(|i| i.label == "Answer the params").unwrap(); + assert!(p.items[..echo].iter().any(|i| i.label == "quit"), "the player's own first"); + + let mut app = app_on(&root); + press(&mut app, ":"); + assert_eq!(entry(&app, "Answer the params").1, None); + press(&mut app, "\u{1b}"); + app.extra_roots.push(root.clone()); + press(&mut app, ":"); + assert_eq!(entry(&app, "Answer the params").1, Some("only on a plugged-in drive")); +} + +/// The drive menu ends with plugins' commands, which run on that drive. +#[test] +fn plugins_commands_are_in_the_drive_menu() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + let info = locallibrary::api::drive_info(&locallibrary::api::DriveRef { drive: root.display().to_string() }); + app.drives = vec![info.unwrap()]; + app.drive_state.select(Some(0)); + app.view = Screen::Drives; + press(&mut app, "e"); + let p = picker(&app); + assert!(p.title.starts_with("Drive · "), "{}", p.title); + assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("Wait until cancelled")); + assert_eq!(entry(&app, "Answer the params").1, None); + press(&mut app, "wait until\n"); + assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Wait until cancelled")); + press(&mut app, "\u{1b}"); + assert!(app.job.is_none()); +} + +/// A command with params asks for them first: one that's needed, one too short and one that +/// isn't of its type are each said on their field, and nothing runs until they do. What it +/// answers shows in a panel. +#[test] +fn a_command_asks_for_its_params_and_answers_in_a_panel() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + press(&mut app, ":answer the params\n"); + let s = screen(&mut app, 120, 40); + for needle in ["Answer the params", "name", "required", "times", "loud", "○ no", "mood", "‹ none ›", "Who to greet"] + { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } + let form = |app: &App| match &app.modal { + Some(Modal::Form(f)) => (f.selected, f.error.clone()), + _ => panic!("no form: {}", last_message(app)), + }; + press(&mut app, "\n"); + assert_eq!(form(&app), (0, Some("name is needed".into()))); + assert!(screen(&mut app, 120, 40).contains("⚠ name is needed")); + press(&mut app, "A\n"); + assert_eq!(form(&app), (0, Some("name: \"A\" is shorter than 2 characters".into()))); + press(&mut app, "da\t3x\n"); + assert_eq!(form(&app), (1, Some("times takes a whole number, not \"3x\"".into()))); + key(&mut app, KeyCode::Backspace); + press(&mut app, "\t "); + key(&mut app, KeyCode::Down); + key(&mut app, KeyCode::Right); + key(&mut app, KeyCode::Right); + press(&mut app, "\t\tdeep, house\n"); + assert!(app.job.is_some(), "running"); + wait(&mut app, |a| a.job.is_none()); + let Some(Modal::Report(r)) = &app.modal else { panic!("no panel: {}", last_message(&app)) }; + assert_eq!(r.title, "Answer the params"); + let s = screen(&mut app, 120, 40); + for needle in ["\"name\": \"Ada\"", "\"times\": 3", "\"loud\": true", "\"mood\": \"wild\"", "\"deep\","] { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } + press(&mut app, "x"); + assert!(app.modal.is_none(), "any other key closes it"); + // Esc closes the form, running nothing. + press(&mut app, ":answer the params\n\u{1b}"); + assert!(app.modal.is_none() && app.job.is_none()); +} + +/// A plugin's hook gets where the user is: the drive, by its id or name, and the tracks marked. +#[test] +fn a_command_runs_where_the_user_is() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + let mut ids: Vec = app.loaded.as_ref().unwrap().library.tracks.iter().map(|t| t.id).collect(); + press(&mut app, "mjm:answer the params\nAda"); + for _ in 0..7 { + key(&mut app, KeyCode::Down); + } + press(&mut app, " \n"); + wait(&mut app, |a| a.job.is_none()); + let text = panel(&app); + let context: Value = serde_json::from_str(&text).unwrap(); + let mut tracks: Vec = serde_json::from_value(context["tracks"].clone()).unwrap(); + ids.sort(); + tracks.sort(); + assert_eq!(tracks, ids, "{text}"); + assert!(context["drive"].as_str().is_some_and(|d| !d.is_empty() && !d.contains('/')), "{text}"); +} + +/// A command without params runs at once, in the background, with esc to stop it; the plugin's +/// log goes to the messages, never on the screen. +#[test] +fn a_command_runs_in_the_background_and_logs_to_the_messages() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + crate::plugins::log_to(Some(app.plugin_log())); + press(&mut app, ":wait until\n"); + assert!(app.modal.is_none()); + assert!(screen(&mut app, 120, 30).contains("Wait until cancelled"), "the header says what's running"); + press(&mut app, "\u{1b}"); + assert_eq!((app.job.is_none(), last_message(&app)), (true, "cancelled")); + + press(&mut app, ":answer the params\nAda"); + for _ in 0..6 { + key(&mut app, KeyCode::Down); + } + press(&mut app, "hello from echo\n"); + wait(&mut app, |a| a.log.iter().any(|(m, ..)| m == "echo: hello from echo")); + assert_ne!(last_message(&app), "echo: hello from echo", "not in the footer"); + crate::plugins::log_to(None); +} + +/// What a command answers: nothing or a line as a message, more as a panel, a view titled as it +/// says. +#[test] +fn answers_are_messages_or_panels() { + let (mut app, _dir) = blank(Screen::Library); + app.show_answer("Tidy".into(), CommandOutput::Headless(Value::Null)); + assert_eq!(last_message(&app), "Tidy: done"); + app.show_answer("Tidy".into(), CommandOutput::Headless(json!("3 files tidied"))); + assert_eq!(last_message(&app), "3 files tidied"); + assert!(app.modal.is_none()); + app.show_answer("Tidy".into(), CommandOutput::Headless(json!("one\ntwo"))); + let Some(Modal::Report(r)) = &app.modal else { panic!() }; + assert_eq!((r.title.as_str(), r.lines.len()), ("Tidy", 2)); + let view = View::Kv { title: Some("Counts".into()), pairs: vec![Pair { label: "tracks".into(), value: json!(2) }] }; + app.show_answer("Tidy".into(), CommandOutput::View(view)); + let s = screen(&mut app, 80, 24); + assert!(s.contains("Counts") && s.contains("tracks 2"), "{s}"); +} + +/// Each kind of view as a panel's lines: a list's details under their labels, a table aligned +/// under its headings (and said to be empty when it is), pairs aligned, text by its lines. +#[test] +fn views_are_drawn_generically() { + let item = |label: &str, detail: Option<&str>| ListItem { label: label.into(), detail: detail.map(String::from) }; + let list = View::List { title: None, items: vec![item("Friday", Some("12 tracks")), item("Warmup", None)] }; + let lines = |v: &View| view_lines(v).into_iter().map(|(t, l)| format!("{t:?} {l}")).collect::>(); + assert_eq!(lines(&list), ["Text Friday", "Faint 12 tracks", "Text Warmup"]); + let rows = vec![vec![json!("Around the World"), json!(121.0)], vec![json!("Pick Up"), Value::Null]]; + let table = View::Table { title: None, columns: vec!["Title".into(), "BPM".into()], rows }; + assert_eq!( + lines(&table), + ["Columns Title BPM", "Row Around the World 121.0", "Row Pick Up -"] + ); + let empty = View::Table { title: None, columns: vec!["Title".into()], rows: Vec::new() }; + assert_eq!(lines(&empty), ["Columns Title", "Faint nothing to show"]); + let pairs = vec![Pair { label: "a".into(), value: json!(1) }, Pair { label: "bpm".into(), value: json!([1, 2]) }]; + assert_eq!(lines(&View::Kv { title: None, pairs }), ["Text a 1", "Text bpm 1, 2"]); + assert_eq!(lines(&View::Text { title: None, text: "one\ntwo".into() }), ["Text one", "Text two"]); + assert_eq!(lines(&View::Text { title: None, text: String::new() }), ["Faint nothing to show"]); + + // A table wider than the panel is cut at its edge, not wrapped, so its columns stay aligned. + let (mut app, _dir) = blank(Screen::Library); + let wide = vec![vec![json!("x".repeat(60)), json!("y".repeat(60)), json!("z".repeat(60))]]; + let view = + View::Table { title: Some("Wide".into()), columns: vec!["A".into(), "B".into(), "C".into()], rows: wide }; + app.show_answer("T".into(), CommandOutput::View(view)); + let s = screen(&mut app, 80, 24); + assert_eq!(s.lines().filter(|l| l.contains("xxxx") || l.contains("zzzz")).count(), 1, "{s}"); +} diff --git a/apps/locallibrary/src/tui/screens.rs b/apps/locallibrary/src/tui/screens.rs index 247541f1..f9ddd693 100644 --- a/apps/locallibrary/src/tui/screens.rs +++ b/apps/locallibrary/src/tui/screens.rs @@ -342,6 +342,38 @@ fn command_palette() { check(&mut app, "the library", &[": commands"], &[]); } +/// A plugin's command in the palette, its form and what it answers, from the plugins' manifests. +#[test] +fn plugins() { + let Fixture { _dir, mut app, .. } = fixture(); + super::app::testing::echo_plugin(_dir.path()); + app.focus = Focus::Tracks; + for c in ":answer the params".chars() { + key(&mut app, KeyCode::Char(c)); + } + check(&mut app, "a plugin's command in the palette", &["Answer the params", "Echo"], &[]); + key(&mut app, KeyCode::Enter); + check( + &mut app, + "a plugin's form", + &["Answer the params", "name", "required", "loud", "○ no", "Who to greet", "enter run · esc cancel"], + &[], + ); + key(&mut app, KeyCode::Enter); + check(&mut app, "a plugin's form, short of a param", &["⚠ name is needed"], &[]); + key(&mut app, KeyCode::Esc); + let rows = vec![vec![serde_json::json!("Demo Track 1"), serde_json::json!(128)]]; + let view = + loclib_plugin::View::Table { title: Some("Titles".into()), columns: vec!["Title".into(), "BPM".into()], rows }; + app.show_answer("List the drive's tracks".into(), locallibrary::plugin::CommandOutput::View(view)); + check( + &mut app, + "a plugin's table", + &["Titles", "Title BPM", "Demo Track 1 128", "any other key closes"], + &[], + ); +} + #[test] fn edit_menu_and_confirmation() { let Fixture { _dir, mut app, root } = fixture(); diff --git a/apps/locallibrary/src/tui/ui.rs b/apps/locallibrary/src/tui/ui.rs index c66db19a..54d3f69a 100644 --- a/apps/locallibrary/src/tui/ui.rs +++ b/apps/locallibrary/src/tui/ui.rs @@ -62,6 +62,7 @@ pub fn draw(f: &mut Frame, app: &mut App) { Some(Modal::Ask(q, _)) => draw_ask(f, q.clone(), f.area()), Some(Modal::Pick(_)) => draw_picker(f, app, f.area()), Some(Modal::Report(_)) => draw_report(f, app, f.area()), + Some(Modal::Form(_)) => draw_form(f, app, f.area()), Some(Modal::Prompt(_)) | None => {} } if app.deck.outputs.is_some() { @@ -312,7 +313,7 @@ fn message_lines(msg: &str, level: Level, width: u16) -> Vec> { fn draw_toasts(f: &mut Frame, app: &App, area: Rect, footer: Rect) { let dialog = matches!( app.modal, - Some(Modal::Menu(_) | Modal::Confirm(_) | Modal::Ask(..) | Modal::Pick(_) | Modal::Report(_)) + Some(Modal::Menu(_) | Modal::Confirm(_) | Modal::Ask(..) | Modal::Pick(_) | Modal::Report(_) | Modal::Form(_)) ); let footer_busy = matches!(app.modal, Some(Modal::Prompt(_))) || app.searching || app.picking.is_some(); let mut lines: Vec = Vec::new(); @@ -2396,7 +2397,8 @@ fn draw_picker(f: &mut Frame, app: &mut App, area: Rect) { app.areas.menu = r; } -/// Results to read (a check's): a panel that scrolls, closed by any other key. +/// Results to read (a check's, a plugin's command's): a panel that scrolls, closed by any other +/// key. fn draw_report(f: &mut Frame, app: &mut App, area: Rect) { use super::drives::Tone; let Some(Modal::Report(r)) = &mut app.modal else { return }; @@ -2409,10 +2411,15 @@ fn draw_report(f: &mut Frame, app: &mut App, area: Rect) { Tone::Ok => ("✓ ", fg(theme::GREEN)), Tone::Warning => ("⚠ ", fg(theme::AMBER)), Tone::Problem => ("✖ ", fg(theme::RED)), - Tone::Text => ("", text()), + Tone::Text | Tone::Row => ("", text()), Tone::Faint => ("", faint()), + Tone::Columns => ("", dim()), }; - for (i, part) in wrap_text(text_, inner_w.saturating_sub(mark.chars().count())).into_iter().enumerate() { + let parts = match tone { + Tone::Columns | Tone::Row => vec![clip(text_, inner_w)], + _ => wrap_text(text_, inner_w.saturating_sub(mark.chars().count())), + }; + for (i, part) in parts.into_iter().enumerate() { let lead = if i == 0 { mark.to_string() } else { " ".repeat(mark.chars().count()) }; lines.push(Line::from(vec![Span::styled(lead, style), Span::styled(part, style)])); } @@ -2428,6 +2435,65 @@ fn draw_report(f: &mut Frame, app: &mut App, area: Rect) { f.render_widget(Paragraph::new(lines).scroll((r.scroll, 0)).block(b), r_area); } +/// A plugin's params to fill in: a row for each field (its label, then what's typed or picked), +/// then what the selected one is for, and why what's filled in won't do. +fn draw_form(f: &mut Frame, app: &mut App, area: Rect) { + use unicode_width::UnicodeWidthStr; + let Some(Modal::Form(form)) = &app.modal else { return }; + let w = 72u16.min(area.width.saturating_sub(2)); + let inner_w = w.saturating_sub(4) as usize; + let label_w = form.fields.iter().map(|x| x.label().width()).max().unwrap_or(0).min(inner_w / 3); + let mut lines: Vec = Vec::new(); + for (i, field) in form.fields.iter().enumerate() { + let on = i == form.selected; + let mut spans = vec![ + Span::styled(if on { "▍" } else { " " }, fg(theme::PINK)), + Span::styled( + format!("{: spans.push(Span::styled("● yes", fg(theme::PINK))), + _ if field.is_switch() => spans.push(Span::styled("○ no", faint())), + picked if !field.choices().is_empty() => { + spans.push(Span::styled("‹ ", faint())); + spans.push(Span::styled(if picked.is_empty() { "none" } else { picked }.to_string(), text())); + spans.push(Span::styled(" ›", faint())); + } + typed if on => { + spans.push(Span::styled(typed_tail(typed, room.saturating_sub(1)).to_string(), bold(theme::FG))); + spans.push(Span::styled("▏", fg(theme::PINK))); + } + typed => spans.push(Span::styled(clip(typed, room), text())), + } + if field.required && field.text.is_empty() { + spans.push(Span::styled("required", faint())); + } + let line = Line::from(spans); + lines.push(if on { line.style(theme::selected()) } else { line }); + } + let selected = &form.fields[form.selected]; + let about = selected.schema["description"].as_str().or(selected.schema["title"].as_str()).unwrap_or_default(); + if !about.is_empty() { + lines.push(Line::raw("")); + lines.extend(wrap_text(about, inner_w).into_iter().map(|l| Line::styled(l, dim()))); + } + if let Some(e) = &form.error { + lines.push(Line::raw("")); + lines.extend(wrap_text(&format!("⚠ {e}"), inner_w).into_iter().map(|l| Line::styled(l, fg(theme::AMBER)))); + } + let r = popup(area, w, lines.len() as u16 + 2, None); + let keys = if selected.choices().is_empty() { "" } else { "←→ change · " }; + let b = block(title(&form.title, true), true) + .title_bottom(Line::styled(format!(" ↑↓ field · {keys}enter run · esc cancel "), faint()).right_aligned()) + .padding(ratatui::widgets::Padding::horizontal(1)); + f.render_widget(Clear, r); + f.render_widget(Paragraph::new(lines).block(b), r); + app.areas.menu = r; +} + /// The outputs to play through (`d`), the system's default first. fn draw_outputs(f: &mut Frame, app: &mut App, area: Rect) { let playing = app.audio.as_ref().map(|a| a.device().map(str::to_string)); diff --git a/crates/locallibrary/src/plugin.rs b/crates/locallibrary/src/plugin.rs index 89750948..ebd18b81 100644 --- a/crates/locallibrary/src/plugin.rs +++ b/crates/locallibrary/src/plugin.rs @@ -1,6 +1,6 @@ //! The plugin host: plugins are programs locallibrary runs and talks to over their stdin and //! stdout ([`loclib_plugin`]'s protocol), adding commands, operations, track analyzers, exporters -//! and settings. The CLI and the RPC sidecar host them through [`Plugins`] (the TUI doesn't yet). +//! and settings. The CLI, the TUI and the RPC sidecar host them through [`Plugins`]. //! //! Only plugins locallibrary ships run for now ([`PluginSpec::first_party`]): the app hands the //! host each one's manifest, compiled in, and how to start it (the locallibrary binary itself, From 10ca8fc5dd57c9847aae969290661b8e23c943c3 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:44:32 -0400 Subject: [PATCH 2/8] tui: plugins' operations in the palette, the drive menu and the actions menu, planned in the background, confirmed with the plugin's summary and warnings, and undone like any edit --- apps/locallibrary/CHANGELOG.md | 6 +- apps/locallibrary/docs/player.md | 18 +- apps/locallibrary/docs/plugins.md | 5 +- apps/locallibrary/src/tui/app.rs | 2 +- apps/locallibrary/src/tui/app/testing.rs | 15 +- apps/locallibrary/src/tui/drives.rs | 10 +- apps/locallibrary/src/tui/edits.rs | 27 ++- apps/locallibrary/src/tui/form.rs | 13 +- apps/locallibrary/src/tui/form/tests.rs | 27 ++- apps/locallibrary/src/tui/keymap.rs | 2 +- apps/locallibrary/src/tui/palette.rs | 4 +- apps/locallibrary/src/tui/plugins.rs | 213 ++++++++++++++++----- apps/locallibrary/src/tui/plugins/tests.rs | 187 +++++++++++++++++- apps/locallibrary/src/tui/screens.rs | 28 ++- apps/locallibrary/src/tui/ui.rs | 14 +- 15 files changed, 469 insertions(+), 102 deletions(-) diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index accc2eac..15d94975 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -215,8 +215,10 @@ Windows builds are experimental and untested on hardware (see Platforms in the R and `track.fields` reads the installed plugins' versions. A plugin whose id or commands clash with locallibrary's or another plugin's is left out, with a warning saying why, and the others work; a plugin's errors keep their kinds (`not_found`, `io`...). In the player, plugins' - commands are in the palette and a drive's menu, asking for their params in a form drawn from - their schemas and answering in a panel, and what plugins log goes to its messages (`!`). See + commands and operations are in the palette and a drive's menu (operations in the actions menu + too), asking for their params in a form drawn from their schemas; a command answers in a panel, + an operation's plan is confirmed, written and undone like any edit, and what plugins log goes + to its messages (`!`). See docs/plugins.md. The ones it ships: - `tracklist`: `playlist import` makes a playlist from a tracklist (M3U, CSV exports of streaming playlists, rekordbox text or XML, "Artist - Title" lines), and `tracklist match` diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index 7b0bee56..52eece1b 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -27,13 +27,17 @@ listening to your library the way the players see it: `fsp` finds "fill the smart playlists") and enter. It also has the commands with no key of their own: pull, check, set prep, sync, pack, restore, replay, export, xml, smart, smart apply, smart new and eject. -- **Plugins' commands** are in the palette by title, with the plugin's name beside them, and at - the end of a drive's `e` menu in the Drives view. One that takes params asks for them in a form - first (`↑↓` or `tab` between fields, `←→` or `space` to pick a choice, `enter` to run; lists - take commas). It runs on the drive open, with the marked or selected tracks and the playlist - shown, in the background (`esc` stops it), and what it answers shows in a panel, or as a - message when it's a line. A plugin that reads the drive needs one plugged in, not a folder - opened by path. What plugins log goes to the messages (`!`). See [Plugins](plugins.md). +- **Plugins' commands and operations** are in the palette by title, with the plugin's name beside + them, and at the end of a drive's `e` menu in the Drives view; operations, which change the + drive, end the actions menu (`e`) too. One that takes params asks for them in a form first + (`↑↓` or `tab` between fields, `←→` or `space` to pick a choice, `enter` to run; lists take + commas), a list of tracks starting with the marked or selected ones. It runs on the drive open, + with the marked or selected tracks and the playlist selected, in the background (`esc` stops + it). What a command answers shows in a panel, or as a message when it's a line; an operation's + plan, with what the plugin says about it first, is confirmed and written like any edit, and `u` + takes it back. An operation, or a command of a plugin that reads the drive, needs one plugged + in, not a folder opened by path. What plugins log goes to the messages (`!`). See + [Plugins](plugins.md). - **Problems**: ✖ marks a track whose audio file isn't on the drive, ≈ one the drive holds another copy of. Browse › Problems lists them. - **Playback** of MP3, AAC/ALAC (`.m4a`), FLAC, WAV and AIFF, with a queue built from the list you diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index 7cd1e1db..a50d4374 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -49,8 +49,9 @@ with each other are both left out. plugin's options as `--option KEY=VALUE`. `--dry-run` lists what it would write (each file, what goes in, what the format can't hold), and overwriting a file is asked about first unless `--replace`. -- **In the player** (`locallibrary tui`), commands are in the palette (`:`) and a drive's menu, - with a form for their params (see [the player](player.md)). +- **In the player** (`locallibrary tui`), commands and operations are in the palette (`:`) and a + drive's menu, operations in the actions menu (`e`) too, with a form for their params (see [the + player](player.md)). - **Settings**: `locallibrary plugin settings ID` shows them, `plugin settings ID KEY VALUE` sets one and `plugin settings ID KEY --reset` puts it back to its default. They're kept in the store (`plugins/ID/settings.json`). diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs index 83b3a5ad..685c08f7 100644 --- a/apps/locallibrary/src/tui/app.rs +++ b/apps/locallibrary/src/tui/app.rs @@ -3206,7 +3206,7 @@ impl App { Act::SmartNew => self.smart_from_search(), Act::ExportList => self.export_list(), Act::ExportXml => self.export_xml(), - Act::Plugin(plugin, name) => self.plugin_act(plugin, name, None), + Act::Plugin(plugin, name) => self.plugin_here(plugin, name), Act::Details => { self.show_details = !self.show_details; if !self.show_details && self.focus == Focus::Details { diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index b4e5c1ac..58cf698d 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -476,8 +476,9 @@ pub fn hold_volume_polls(app: &mut App) { } /// loclib-plugin's echo plugin, which the plugin host's test plugin serves: its export format -/// renamed (`m3u` is locallibrary's), and its `echo` command taking a param of each kind the -/// player's form fills in, besides the test plugin's `log` and `context`. +/// renamed (`m3u` is locallibrary's), its `echo` command taking a param of each kind the player's +/// form fills in, besides the test plugin's `log` and `context`, and its `playlist` operation +/// `tracks` too (which it leaves be). const ECHO: &str = include_str!("../../../../../crates/loclib-plugin/examples/echo/locallibrary-plugin.toml"); /// The plugins with the echo plugin among them, for this test: its manifest written in `dir` and @@ -485,8 +486,14 @@ const ECHO: &str = include_str!("../../../../../crates/loclib-plugin/examples/ec /// before anything opens the palette or a menu. pub fn echo_plugin(dir: &Path) { let params = r#"params = { type = "object", required = ["name"], properties = { name = { type = "string", minLength = 2, description = "Who to greet" }, times = { type = "integer" }, loud = { type = "boolean" }, mood = { type = "string", enum = ["calm", "wild"] }, file = { type = "string", x-path = true }, tags = { type = "array", items = { type = "string" } }, log = { type = "string" }, context = { type = "boolean" } } }"#; - let manifest = - ECHO.replace("format = \"m3u\"", "format = \"echo-m3u\"").replacen("params = { type = \"object\" }", params, 1); + let operation = r#"params = { type = "object", properties = { name = { type = "string" }, tracks = { type = "array", items = { type = "integer" } } }, required = ["name"] }"#; + let manifest = ECHO + .replace("format = \"m3u\"", "format = \"echo-m3u\"") + .replacen("params = { type = \"object\" }", params, 1) + .replace( + r#"params = { type = "object", properties = { name = { type = "string" } }, required = ["name"] }"#, + operation, + ); let file = dir.join("echo.toml"); std::fs::write(&file, manifest).unwrap(); // SAFETY: nextest runs each test in a process of its own, and this runs before the test diff --git a/apps/locallibrary/src/tui/drives.rs b/apps/locallibrary/src/tui/drives.rs index a5affba0..b0b1fdeb 100644 --- a/apps/locallibrary/src/tui/drives.rs +++ b/apps/locallibrary/src/tui/drives.rs @@ -322,14 +322,14 @@ impl App { Act::Replay => self.replay(root), Act::Undo => self.undo_on(root), Act::Eject => self.eject(vec![root]), - Act::Plugin(plugin, name) => self.plugin_act(plugin, name, Some(root)), + Act::Plugin(plugin, name) => self.plugin_act(plugin, name, Some(root), None), act => self.perform(act, None), } } - /// `e` in the Drives view: what can be done to the selected drive (plugins' commands last). - /// What's chosen is done to that drive (by its identity: see [`DriveKey`]), even if another - /// is selected by then, or refused once it's been unplugged. + /// `e` in the Drives view: what can be done to the selected drive (plugins' commands and + /// operations last). What's chosen is done to that drive (by its identity: see [`DriveKey`]), + /// even if another is selected by then, or refused once it's been unplugged. pub(super) fn drive_menu(&mut self) { match self.header() { Some(DriveRow::Library(id)) => return self.library_menu(id), @@ -356,7 +356,7 @@ impl App { item(Act::Undo, undo_key, &None), item(Act::Eject, "", &None), ]; - let (acts, items): (Vec, Vec) = items.into_iter().chain(self.plugin_commands(Some(&root))).unzip(); + let (acts, items): (Vec, Vec) = items.into_iter().chain(self.plugin_items(Some(&root))).unzip(); let drive = self.drive_at(root); self.modal = Some(Modal::Pick(Picker::new(format!("Drive · {name}"), items, Then::Drive { drive, name, acts }))); diff --git a/apps/locallibrary/src/tui/edits.rs b/apps/locallibrary/src/tui/edits.rs index acbe0d74..ae5cdfc2 100644 --- a/apps/locallibrary/src/tui/edits.rs +++ b/apps/locallibrary/src/tui/edits.rs @@ -1,10 +1,10 @@ //! Changing the drive from the player, always opt-in: an actions menu (`e`, or right-click) for -//! the marked or selected tracks, the playlist in view and the crate; the prompts and playlist -//! picking some actions need; and the job every write goes through: plan it in the background, -//! show the plan and wait for a yes, apply exactly that plan (the library backs the drive up -//! first, and refuses if the drive changed since), then read the drive again, keeping the view. -//! Any kind of plan goes through the same dialog ([`AnyPlanned`]); playlist and track edits show -//! in detail. +//! the marked or selected tracks, the playlist in view and the crate (plugins' operations last); +//! the prompts and playlist picking some actions need; and the job every write goes through: plan +//! it in the background, show the plan and wait for a yes, apply exactly that plan (the library +//! backs the drive up first, and refuses if the drive changed since), then read the drive again, +//! keeping the view. Any kind of plan goes through the same dialog ([`AnyPlanned`]); playlist and +//! track edits show in detail. use std::path::PathBuf; use std::sync::Mutex; @@ -118,6 +118,8 @@ pub enum Action { Restore, /// Give the tracks a My Tag, or take one off. Tag, + /// A plugin's operation, by the plugin's id and its name. + Plugin(&'static str, &'static str), } /// What an action works on, captured when the menu opens so moving about afterwards (picking a @@ -142,6 +144,8 @@ pub struct Target { pub crate_total: usize, /// The drive was unplugged. pub stale: bool, + /// The drive is a folder opened by path, not one plugged in: plugins can't name it. + pub folder_drive: bool, } pub struct MenuItem { @@ -260,8 +264,15 @@ pub fn menu_items(t: &Target) -> Vec { Action::Restore, stale.clone(), ), - item("y", keymap::name(Act::Replay).into(), Action::Replay, stale), + item("y", keymap::name(Act::Replay).into(), Action::Replay, stale.clone()), ] + .into_iter() + .chain( + super::plugins::offered(Some(!t.folder_drive), false) + .into_iter() + .map(|o| item("", o.label, Action::Plugin(o.plugin, o.name), stale.clone().or(o.unavailable))), + ) + .collect() } // --------------------------------------------------------------------------------------------- @@ -565,6 +576,7 @@ impl App { crated, crate_total: self.crate_list.len(), stale: l.stale, + folder_drive: self.pluggable(Some(l.root())) == Some(false), }) } @@ -826,6 +838,7 @@ impl App { Action::Replay => self.replay(t.root.clone()), Action::Tag => self.tag_tracks(), Action::Restore => self.restore_playlist(t.root.clone()), + Action::Plugin(plugin, name) => self.plugin_act(plugin, name, Some(t.root.clone()), Some(&t)), Action::RatingMenu | Action::ColourMenu => {} } } diff --git a/apps/locallibrary/src/tui/form.rs b/apps/locallibrary/src/tui/form.rs index 6d93ece6..4df9a193 100644 --- a/apps/locallibrary/src/tui/form.rs +++ b/apps/locallibrary/src/tui/form.rs @@ -65,24 +65,27 @@ impl Field { } impl Form { - /// The form for `run`'s params, each field filled in with its default (none when its command - /// takes none). + /// The form for `run`'s params, each field filled in with its default (none when it takes + /// none), or a list of track ids called `tracks` with the tracks selected. pub fn new(run: Run) -> Form { - let schema = run.command.params.as_ref(); + let schema = run.params(); let required: Vec<&str> = schema.and_then(|s| s["required"].as_array()).into_iter().flatten().filter_map(Value::as_str).collect(); let fields: Vec = properties(schema) .map(|(name, schema)| { let required = required.contains(&name.as_str()); let mut field = Field { name: name.clone(), schema: schema.clone(), required, text: String::new() }; + let ids = &run.context.tracks; + let selected = name == "tracks" && takes(schema) == Some(Takes::Many("integer")) && !ids.is_empty(); field.text = match schema.get("default") { Some(default) => cell(default), + None if selected => ids.iter().map(u32::to_string).collect::>().join(", "), None => field.choices().first().cloned().unwrap_or_default(), }; field }) .collect(); - Form { title: run.command.title.clone(), fields, selected: 0, error: None, run } + Form { title: run.title().to_string(), fields, selected: 0, error: None, run } } /// The params filled in, or which field won't do and why: one that's needed is empty, one @@ -112,7 +115,7 @@ impl Form { given.insert(f.name.clone(), params::value(&f.schema, &raws, &f.label()).map_err(|e| (i, e))?); } let given = Value::Object(given); - check_params(self.run.command.params.as_ref(), &given).map_err(|e| { + check_params(self.run.params(), &given).map_err(|e| { // The field the schema's complaint is about (`/into`), named as it's shown. let path = e.data.and_then(|d| d.fields.get("path").and_then(Value::as_str).map(String::from)); let at = path.as_deref().and_then(|p| p.trim_start_matches('/').split('/').next()); diff --git a/apps/locallibrary/src/tui/form/tests.rs b/apps/locallibrary/src/tui/form/tests.rs index 0035cd1d..444e32f7 100644 --- a/apps/locallibrary/src/tui/form/tests.rs +++ b/apps/locallibrary/src/tui/form/tests.rs @@ -1,3 +1,4 @@ +use locallibrary::plugin::Cli; use loclib_plugin::{Command, Context, Manifest}; use serde_json::json; @@ -7,13 +8,18 @@ use super::*; /// A form for a command whose params are `params` (a JSON Schema, as TOML). fn form(params: &str) -> Form { + form_in(params, Context::default()) +} + +/// [`form`], where `context` says what's selected. +fn form_in(params: &str, context: Context) -> Form { let manifest = format!( "id = \"t\"\nname = \"T\"\nversion = \"0.1.0\"\nprotocol = \"^0.1\"\n\ [[contributes.commands]]\nname = \"c\"\ntitle = \"Do it\"\nmode = \"headless\"\n{params}" ); let manifest = Manifest::parse(&manifest).unwrap(); let command: &'static Command = Box::leak(Box::new(manifest.contributes.commands[0].clone())); - Form::new(Run { plugin: "t", command, context: Context::default() }) + Form::new(Run { plugin: "t", hook: Cli::Command(command), root: None, context }) } fn filled(f: &mut Form, texts: &[&str]) -> Result { @@ -44,6 +50,25 @@ fn fields_start_at_their_defaults() { assert!(f.fields[0].choices().is_empty()); } +/// A list of track ids called `tracks` starts as the tracks selected, which it then takes; one +/// called otherwise, of another kind, with a default or with none selected starts empty. +#[test] +fn tracks_start_as_the_ones_selected() { + let selected = Context { tracks: vec![3, 812], ..Context::default() }; + let ids = r#"type = "array", items = { type = "integer" }"#; + let schema = format!( + r#"params = {{ type = "object", properties = {{ tracks = {{ {ids} }}, others = {{ {ids} }}, also = {{ {ids}, default = [1] }} }} }}"# + ); + let f = form_in(&schema, selected.clone()); + let texts: Vec<&str> = f.fields.iter().map(|x| x.text.as_str()).collect(); + assert_eq!(texts, ["3, 812", "", "1"]); + assert_eq!(f.params().unwrap()["tracks"], json!([3, 812])); + assert_eq!(form_in(&schema, Context::default()).fields[0].text, ""); + let names = + r#"params = { type = "object", properties = { tracks = { type = "array", items = { type = "string" } } } }"#; + assert_eq!(form_in(names, selected).fields[0].text, ""); +} + /// What's typed is read as the command line reads it: numbers, lists with commas between them, /// pairs, paths made absolute (`~` for home); what's left empty is left out. #[test] diff --git a/apps/locallibrary/src/tui/keymap.rs b/apps/locallibrary/src/tui/keymap.rs index 641db12f..b84bc73e 100644 --- a/apps/locallibrary/src/tui/keymap.rs +++ b/apps/locallibrary/src/tui/keymap.rs @@ -362,7 +362,7 @@ pub static KEYMAP: &[Binding] = &[ key(S::Library, &[K::Ch('t')], "t", A::CratePanel, "show or hide the crate", "show / hide the crate: tracks collected while browsing, a CDJ's TAG LIST", G::Crate), note(S::Library, "enter", "in the crate: play from there", G::Crate), note(S::Library, "", "Every change shows what it will do and asks first; the drive is backed up before it's written.", G::Changing), - key(S::Library, &[K::Ch('e')], "e", A::Actions, "actions that change the drive", "actions for the marked or selected tracks, the playlist shown and the crate: rating, colour, add to a playlist, remove, new / rename / delete a playlist or folder, sort a playlist on the drive", G::Changing), + key(S::Library, &[K::Ch('e')], "e", A::Actions, "actions that change the drive", "actions for the marked or selected tracks, the playlist shown and the crate: rating, colour, add to a playlist, remove, new / rename / delete a playlist or folder, sort a playlist on the drive, plugins' operations", G::Changing), note(S::Library, "right-click", "the same, for what's under the pointer", G::Changing), key(S::Library, &[K::Ch('T')], "T", A::Tag, "give the tracks a My Tag, or take one off", "give the marked (or selected) tracks a My Tag, or take one off (asks first)", G::Changing), note(S::Library, "y", "after rekordbox exported over edits made here: replay them (it says so above the footer; esc puts it away)", G::Changing), diff --git a/apps/locallibrary/src/tui/palette.rs b/apps/locallibrary/src/tui/palette.rs index a005ca47..f3c9c944 100644 --- a/apps/locallibrary/src/tui/palette.rs +++ b/apps/locallibrary/src/tui/palette.rs @@ -182,10 +182,10 @@ pub fn commands(view: View) -> Vec<(Act, Item)> { } impl App { - /// `:`: every action by name, then plugins' commands. + /// `:`: every action by name, then plugins' commands and operations. pub(super) fn open_palette(&mut self) { let (acts, items): (Vec, Vec) = - commands(self.view).into_iter().chain(self.plugin_commands(self.action_drive().as_deref())).unzip(); + commands(self.view).into_iter().chain(self.plugin_items(self.action_drive().as_deref())).unzip(); let mut p = Picker::new("Commands", items, Then::Act(acts)); p.empty = "no command has those letters"; self.modal = Some(Modal::Pick(p)); diff --git a/apps/locallibrary/src/tui/plugins.rs b/apps/locallibrary/src/tui/plugins.rs index 3bdeee0c..c8577755 100644 --- a/apps/locallibrary/src/tui/plugins.rs +++ b/apps/locallibrary/src/tui/plugins.rs @@ -1,30 +1,119 @@ -//! Plugins in the player: their commands in the palette and the drive menu, a form for their -//! params (see [`super::form`]), run in the background as any task is (esc stops one), and what -//! they answer in a panel. Nothing here tells one plugin from another: it's all drawn from their -//! manifests, through the host the command line uses (see [`crate::plugins`]). +//! Plugins in the player: their commands and operations in the palette and the drive menu +//! (operations in the actions menu, `e`, too), a form for their params (see [`super::form`]), and +//! each run in the background as any task is (esc stops one). A command's answer shows in a panel; +//! an operation's plan is confirmed and applied as any edit is, and undone with `u`. Nothing here +//! tells one plugin from another: it's all drawn from their manifests, through the host the +//! command line uses (see [`crate::plugins`]). use std::path::{Path, PathBuf}; use locallibrary::Store; -use locallibrary::plugin::CommandOutput; -use loclib_plugin::{Capability, Command, Context, View}; +use locallibrary::plugin::{Cli, CommandOutput}; +use loclib_plugin::{Capability, Context, View}; use serde_json::Value; use super::app::{App, Level, View as Screen}; use super::drives::{Report, Tone}; -use super::edits::Modal; +use super::edits::{Modal, Target}; use super::form::Form; use super::keymap::Act; use super::palette::Item; use crate::plugin_cmd::view::{pairs, table}; +use crate::plugins::params::properties; -/// A plugin's command, and where it runs: the drive, and what's selected there. +/// A plugin's command or operation, and where it runs: the drive, and what's selected there (its +/// context's drive is the drive's). pub struct Run { pub plugin: &'static str, - pub command: &'static Command, + pub hook: Cli<'static>, + pub root: Option, pub context: Context, } +impl Run { + /// Its title, as its manifest has it. + pub fn title(&self) -> &'static str { + match self.hook { + Cli::Command(c) => &c.title, + Cli::Operation(o) => &o.title, + } + } + + /// The JSON Schema of its params. + pub fn params(&self) -> Option<&'static Value> { + match self.hook { + Cli::Command(c) => c.params.as_ref(), + Cli::Operation(o) => o.params.as_ref(), + } + } +} + +/// A plugin's command or operation, as a menu offers it. +pub struct Offered { + pub plugin: &'static str, + pub name: &'static str, + /// Its title as the player's own are named (see [`named`]), ending in … when it asks for + /// params first. + pub label: String, + /// Its plugin's name. + pub by: &'static str, + /// Why it can't run here, if it can't. + pub unavailable: Option, +} + +/// Plugins' commands (with `commands`) and operations, as the menus offer them on a drive: +/// `drive` is `None` without one, `Some(false)` for a folder opened by path, which a plugin can't +/// name. An operation always needs a drive; a command, when its plugin reads one. +pub fn offered(drive: Option, commands: bool) -> Vec { + let Ok(plugins) = crate::plugins::host() else { return Vec::new() }; + let why = match drive { + None => Some("open a drive first"), + Some(false) => Some("only on a plugged-in drive"), + Some(true) => None, + }; + let mut all = Vec::new(); + for m in plugins.manifests() { + let c = &m.contributes; + let reads = m.capabilities.iter().any(reads_drive); + let hooks = c.commands.iter().filter(|_| commands).map(|x| (&x.name, &x.title, x.params.as_ref(), reads)); + let hooks = hooks.chain(c.operations.iter().map(|x| (&x.name, &x.title, x.params.as_ref(), true))); + for (name, title, params, needs_drive) in hooks { + let asks = if properties(params).next().is_some() { "…" } else { "" }; + all.push(Offered { + plugin: &m.id, + name, + label: format!("{}{asks}", named(title)), + by: &m.name, + unavailable: why.filter(|_| needs_drive).map(String::from), + }); + } + } + all +} + +/// A title as the player names its own commands: its first letter lowercase, unless it starts a +/// name in capitals (`M3U`). +fn named(title: &str) -> String { + let mut chars = title.chars(); + match (chars.next(), chars.next()) { + (Some(first), Some(second)) if !second.is_uppercase() && !second.is_numeric() => { + first.to_lowercase().chain(title.chars().skip(1)).collect() + } + _ => title.to_string(), + } +} + +/// The name of the plugin whose operation `operation` (`.`, as its plan is called) +/// is, if it's a plugin's. +pub fn planned_by(operation: &str) -> Option<&'static str> { + let mut all = crate::plugins::host().ok()?.manifests(); + let ours = all.find(|m| { + let name = operation.strip_prefix(m.id.as_str()).and_then(|rest| rest.strip_prefix('.')); + name.is_some_and(|name| m.contributes.operations.iter().any(|o| o.name == name)) + }); + ours.map(|m| m.name.as_str()) +} + /// Whether a plugin with `capability` reads a drive: it names one when it calls back. fn reads_drive(capability: &Capability) -> bool { use Capability::*; @@ -71,65 +160,83 @@ pub fn view_lines(v: &View) -> Vec<(Tone, String)> { } impl App { - /// Every plugin's commands, as the palette and the drive menu list them for the drive at - /// `root`: by title, with the plugin's name beside each. Those of plugins that read a drive - /// can't run without one, nor on a drive opened as a folder, which a plugin can't name. - pub(super) fn plugin_commands(&self, root: Option<&Path>) -> Vec<(Act, Item)> { - let Ok(plugins) = crate::plugins::host() else { return Vec::new() }; - let why = match root { - None => Some("open a drive first"), - Some(root) if self.extra_roots.iter().any(|r| r == root) => Some("only on a plugged-in drive"), - Some(_) => None, - }; - let all = plugins.manifests().flat_map(|m| m.contributes.commands.iter().map(move |c| (m, c))); - all.map(|(m, c)| { - let reads = m.capabilities.iter().any(reads_drive); - let unavailable = why.filter(|_| reads).map(String::from); - (Act::Plugin(&m.id, &c.name), Item { unavailable, ..Item::new(&c.title, &m.name) }) - }) - .collect() + /// Whether plugins can run on the drive at `root`: one plugged in, rather than a folder + /// opened by path. `None` without a drive. + pub(super) fn pluggable(&self, root: Option<&Path>) -> Option { + root.map(|root| !self.extra_roots.iter().any(|r| r == root)) + } + + /// Every plugin's commands and operations, as the palette and the drive menu list them for + /// the drive at `root`: by title, with the plugin's name beside each. + pub(super) fn plugin_items(&self, root: Option<&Path>) -> Vec<(Act, Item)> { + let all = offered(self.pluggable(root), true).into_iter(); + all.map(|o| (Act::Plugin(o.plugin, o.name), Item { unavailable: o.unavailable, ..Item::new(o.label, o.by) })) + .collect() + } + + /// What's selected, as a plugin's hook takes it: the tracks (by id) and the playlist (not a + /// folder) the actions menu works on. + fn context(&self, t: &Target) -> Context { + let l = self.loaded.as_ref().filter(|l| l.root() == t.root); + let ids = l.map(|l| t.tracks.iter().filter_map(|p| l.index_of(p)).map(|i| l.library.tracks[i].id).collect()); + let playlist = t.node.clone().filter(|(_, folder)| !folder).map(|(path, _)| path); + Context { playlist, tracks: ids.unwrap_or_default(), ..Context::default() } } - /// Where the user is, as a plugin's hook takes it: the drive actions work on, and in the - /// library the playlist shown and the tracks marked or selected (by id). - fn context(&self) -> Context { - let drive = self.action_drive().map(|r| r.display().to_string()); - let target = (self.view == Screen::Library).then(|| self.target().zip(self.loaded.as_ref())).flatten(); - let Some((t, l)) = target else { return Context { drive, ..Context::default() } }; - let tracks = t.tracks.iter().filter_map(|p| l.index_of(p)).map(|i| l.library.tracks[i].id).collect(); - Context { drive, playlist: t.list, tracks, ..Context::default() } + /// A plugin's command or operation from the palette: on the drive actions work on, with what's + /// selected in the library. + pub(super) fn plugin_here(&mut self, plugin: &'static str, name: &'static str) { + let target = (self.view == Screen::Library).then(|| self.target()).flatten(); + self.plugin_act(plugin, name, self.action_drive(), target.as_ref()); } - /// A plugin's command, from the palette (where the user is) or the drive menu (on the drive - /// at `root`): its form first when it takes params. - pub(super) fn plugin_act(&mut self, plugin: &'static str, name: &'static str, root: Option) { - let manifest = crate::plugins::host().and_then(|p| p.manifest(plugin)); - let Some(command) = manifest.ok().and_then(|m| m.contributes.commands.iter().find(|c| c.name == name)) else { + /// A plugin's command or operation, on the drive at `root` with what `target` selects: its + /// form first when it takes params. + pub(super) fn plugin_act( + &mut self, + plugin: &'static str, + name: &'static str, + root: Option, + target: Option<&Target>, + ) { + let Ok(m) = crate::plugins::host().and_then(|p| p.manifest(plugin)) else { return }; + let c = &m.contributes; + let command = c.commands.iter().find(|x| x.name == name).map(Cli::Command); + let Some(hook) = command.or_else(|| c.operations.iter().find(|x| x.name == name).map(Cli::Operation)) else { return; }; - let context = match root { - Some(root) => Context { drive: Some(root.display().to_string()), ..Context::default() }, - None => self.context(), - }; - let form = Form::new(Run { plugin, command, context }); + let context = target.map(|t| self.context(t)).unwrap_or_default(); + let form = Form::new(Run { plugin, hook, root, context }); match form.fields.is_empty() { true => self.run_plugin(form.run, Value::Null), false => self.modal = Some(Modal::Form(form)), } } - /// Run a plugin's command with `params` in the background; what it answers shows once it's - /// done. + /// Run a plugin's command with `params` in the background, what it answers showing once it's + /// done; or plan its operation, the plan to confirm showing once it's made. pub(super) fn run_plugin(&mut self, run: Run, params: Value) { let Ok(plugins) = crate::plugins::host() else { return }; - let Run { plugin, command, context } = run; + let Run { plugin, hook, root, context } = run; + let context = Context { drive: root.as_ref().map(|r| r.display().to_string()), ..context }; let store_root = self.store_root.clone(); - let title = command.title.clone(); - self.task(title.clone(), move |ctx| { - let store = Store::open(&store_root).ok(); - let answer = plugins.run_command(plugin, &command.name, params, context, store.as_ref(), ctx)?; - Ok(Box::new(move |app: &mut App| app.show_answer(title, answer))) - }); + match hook { + Cli::Command(command) => { + let title = command.title.clone(); + self.task(title.clone(), move |ctx| { + let store = Store::open(&store_root).ok(); + let answer = plugins.run_command(plugin, &command.name, params, context, store.as_ref(), ctx)?; + Ok(Box::new(move |app: &mut App| app.show_answer(title, answer))) + }); + } + Cli::Operation(op) => { + let Some(root) = root else { return self.toast("open a drive first", Level::Info) }; + self.plan(root, move |ctx| { + let store = Store::open(&store_root).ok(); + Ok(Box::new(plugins.plan_op(plugin, &op.name, params, context, store.as_ref(), ctx)?)) + }); + } + } } /// What a command answered: nothing or a line of text as a message, a view in a panel diff --git a/apps/locallibrary/src/tui/plugins/tests.rs b/apps/locallibrary/src/tui/plugins/tests.rs index 938f4227..9307a57b 100644 --- a/apps/locallibrary/src/tui/plugins/tests.rs +++ b/apps/locallibrary/src/tui/plugins/tests.rs @@ -2,7 +2,9 @@ use loclib_plugin::{ListItem, Pair}; use ratatui::crossterm::event::KeyCode; use serde_json::json; -use super::super::app::testing::{app_on, blank, demo_drive, echo_plugin, key, last_message, press, wait}; +use super::super::app::testing::{ + app_on, blank, confirming, demo_drive, echo_plugin, key, last_message, press, say_yes, wait, +}; use super::super::palette::Picker; use super::super::ui::screen; use super::*; @@ -38,23 +40,23 @@ fn plugins_commands_are_in_the_palette() { echo_plugin(dir.path()); let (mut app, _store) = blank(Screen::Library); press(&mut app, ":"); - let (item, why) = entry(&app, "Answer the params"); + let (item, why) = entry(&app, "answer the params…"); assert_eq!((item.detail.as_str(), why), ("Echo", Some("open a drive first"))); - assert_eq!(entry(&app, "Find a tracklist's tracks").0.detail, "Tracklist import"); + assert_eq!(entry(&app, "find a tracklist's tracks…").0.detail, "Tracklist import"); let p = picker(&app); - let echo = p.items.iter().position(|i| i.label == "Answer the params").unwrap(); + let echo = p.items.iter().position(|i| i.label == "answer the params…").unwrap(); assert!(p.items[..echo].iter().any(|i| i.label == "quit"), "the player's own first"); let mut app = app_on(&root); press(&mut app, ":"); - assert_eq!(entry(&app, "Answer the params").1, None); + assert_eq!(entry(&app, "answer the params…").1, None); press(&mut app, "\u{1b}"); app.extra_roots.push(root.clone()); press(&mut app, ":"); - assert_eq!(entry(&app, "Answer the params").1, Some("only on a plugged-in drive")); + assert_eq!(entry(&app, "answer the params…").1, Some("only on a plugged-in drive")); } -/// The drive menu ends with plugins' commands, which run on that drive. +/// The drive menu ends with plugins' commands and operations, which run on that drive. #[test] fn plugins_commands_are_in_the_drive_menu() { let (dir, root) = demo_drive(); @@ -67,8 +69,8 @@ fn plugins_commands_are_in_the_drive_menu() { press(&mut app, "e"); let p = picker(&app); assert!(p.title.starts_with("Drive · "), "{}", p.title); - assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("Wait until cancelled")); - assert_eq!(entry(&app, "Answer the params").1, None); + assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("make a playlist…")); + assert_eq!(entry(&app, "answer the params…").1, None); press(&mut app, "wait until\n"); assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Wait until cancelled")); press(&mut app, "\u{1b}"); @@ -216,3 +218,170 @@ fn views_are_drawn_generically() { let s = screen(&mut app, 80, 24); assert_eq!(s.lines().filter(|l| l.contains("xxxx") || l.contains("zzzz")).count(), 1, "{s}"); } + +/// The actions menu (`e`) open now, as its items' labels and why each can't be done. +fn menu(app: &App) -> Vec<(String, Option)> { + match &app.modal { + Some(Modal::Menu(m)) => m.items.iter().map(|i| (i.label.clone(), i.unavailable.clone())).collect(), + _ => panic!("no menu: {}", last_message(app)), + } +} + +/// Operations change a drive, so they need one plugged in, whatever their plugin reads: in the +/// palette, and at the end of the actions menu (`e`), where an unplugged drive says so too. Those +/// that ask for params first end in "…". +#[test] +fn operations_are_offered_where_they_can_run() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + // An echo plugin that reads nothing: its commands run without a drive. + let file = dir.path().join("echo.toml"); + let manifest = std::fs::read_to_string(&file).unwrap(); + let manifest = + manifest.replace(r#"capabilities = ["library:read", "library:plan"]"#, r#"capabilities = ["library:plan"]"#); + std::fs::write(&file, manifest).unwrap(); + let (mut app, _store) = blank(Screen::Library); + press(&mut app, ":"); + assert_eq!(entry(&app, "answer the params…").1, None); + assert_eq!(entry(&app, "make a playlist…").1, Some("open a drive first")); + assert_eq!(entry(&app, "make a playlist…").0.detail, "Echo"); + assert_eq!(entry(&app, "fetch cover art…").1, Some("open a drive first")); + + let mut app = app_on(&root); + press(&mut app, ":"); + assert_eq!(entry(&app, "make a playlist…").1, None); + press(&mut app, "\u{1b}e"); + let items = menu(&app); + let echo = items.iter().position(|(l, _)| l == "make a playlist…").expect("in the menu"); + assert!(items[..echo].iter().any(|(l, _)| l == "clear the crate"), "after the player's own"); + assert!(items.iter().all(|(l, _)| l != "answer the params…"), "commands change nothing: not here"); + assert_eq!(items[echo].1, None); + press(&mut app, "\u{1b}"); + app.loaded.as_mut().unwrap().stale = true; + press(&mut app, "e"); + assert_eq!(menu(&app)[echo].1.as_deref(), Some("the drive was unplugged")); + press(&mut app, "\u{1b}"); + app.loaded.as_mut().unwrap().stale = false; + app.extra_roots.push(root.clone()); + press(&mut app, "e"); + assert_eq!(menu(&app)[echo].1.as_deref(), Some("only on a plugged-in drive")); +} + +/// An operation from the actions menu: its form starts with the marked tracks, it's planned in +/// the background, its plan (the plugin's summary first) is confirmed as any edit is, applied +/// with a backup, the drive read again, and `u` takes it back. +#[test] +fn an_operation_is_planned_confirmed_applied_and_undone() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + let ids: Vec = app.loaded.as_ref().unwrap().library.tracks.iter().map(|t| t.id.to_string()).collect(); + press(&mut app, "mjme"); + let echo = menu(&app).iter().position(|(l, _)| l == "make a playlist…").unwrap(); + for _ in 0..echo { + press(&mut app, "j"); + } + press(&mut app, "\n"); + let Some(Modal::Form(f)) = &app.modal else { panic!("no form: {}", last_message(&app)) }; + assert_eq!(f.fields[1].text, ids.join(", "), "the marked tracks"); + press(&mut app, "Plugged\n"); + assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Planning")); + assert_eq!(confirming(&mut app), ("echo.playlist".into(), "make the playlist Plugged".into())); + let s = screen(&mut app, 120, 40); + for needle in ["Write to ", "Echo: make the playlist Plugged", "+ Plugged", "y write", "u takes this back"] { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } + say_yes(&mut app); + assert!(last_message(&app).contains("u to undo"), "{}", last_message(&app)); + let has = |app: &App| app.loaded.as_ref().unwrap().library.playlists.iter().any(|p| p.name == "Plugged"); + assert!(has(&app), "read again"); + press(&mut app, "u"); + say_yes(&mut app); + assert!(!has(&app), "undone: {}", last_message(&app)); +} + +/// Planning an operation is stopped with esc, and nothing shows; planned again, it's confirmed. +/// A plan that changes nothing says so, and one the plugin can't make says why, as an error. +#[test] +fn planning_an_operation_is_stopped_with_esc() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + press(&mut app, ":make a playlist…\nGone\n"); + assert!(app.job.is_some()); + press(&mut app, "\u{1b}"); + assert_eq!((app.job.is_none(), last_message(&app)), (true, "cancelled")); + press(&mut app, ":make a playlist…\nKept\n"); + assert_eq!(confirming(&mut app).1, "make the playlist Kept"); + assert!(app.waiting.is_empty()); + say_yes(&mut app); + press(&mut app, ":make a playlist…\nKept\n"); + wait(&mut app, |a| a.job.is_none()); + assert_eq!((app.modal.is_none(), last_message(&app)), (true, "nothing to change")); + press(&mut app, ":make a playlist from a tracklist\n/nowhere/list.m3u\tFriday\n"); + wait(&mut app, |a| a.job.is_none()); + assert!(app.modal.is_none()); + let (said, level) = app.toast.clone().map(|t| (t.0, t.1)).unwrap(); + assert!(level == Level::Error && said.contains("/nowhere/list.m3u"), "{said}"); +} + +/// A plan is a plugin's when its operation is one of a plugin's, by the plugin's id and its name. +#[test] +fn plans_are_known_by_their_plugins() { + let (dir, _root) = demo_drive(); + echo_plugin(dir.path()); + assert_eq!(planned_by("echo.playlist"), Some("Echo")); + assert_eq!(planned_by("tracklist.import"), Some("Tracklist import")); + for not in ["echo.echo", "echo.nope", "echo", "edit", "edits.replay", "echoplaylist"] { + assert_eq!(planned_by(not), None, "{not}"); + } +} + +/// Titles are named as the player's own commands are, a name in capitals kept. +#[test] +fn titles_are_named_as_the_players_own() { + assert_eq!(named("Fetch cover art"), "fetch cover art"); + assert_eq!(named("M3U playlist"), "M3U playlist"); + assert_eq!(named("A"), "A"); + assert_eq!(named("A playlist"), "a playlist"); + assert_eq!(named("3D view"), "3D view"); + assert_eq!(named("Éclair"), "éclair"); + assert_eq!(named(""), ""); +} + +/// The confirmation has what the plugin says about its plan, its warnings too, besides the edits. +#[test] +fn plugin_warnings_show_in_the_confirmation() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + let plugins = crate::plugins::host().unwrap(); + // The test plugin plans the edits it's given, saying "as given" and warning "given". + let params = json!({ "name": "x", "edits": [{ "op": "create_playlist", "path": "Given" }] }); + let context = Context { drive: Some(root.display().to_string()), ..Context::default() }; + let plan = plugins.plan_op("echo", "playlist", params, context, None, &locallibrary::Ctx::silent()).unwrap(); + app.show(Modal::Confirm(super::super::edits::Confirm { + drive: "DEMO".into(), + root, + plan: Box::new(plan), + scroll: 0, + shown: std::time::Instant::now(), + after: None, + })); + let s = screen(&mut app, 120, 40); + for needle in ["Echo: as given", "+ Given", "⚠ given"] { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } +} + +/// An operation is planned on a drive: without one, it says so. +#[test] +fn an_operation_without_a_drive_isnt_planned() { + let (dir, _root) = demo_drive(); + echo_plugin(dir.path()); + let (mut app, _store) = blank(Screen::Library); + let manifest = crate::plugins::host().unwrap().manifest("echo").unwrap(); + let hook = Cli::Operation(&manifest.contributes.operations[0]); + app.run_plugin(Run { plugin: "echo", hook, root: None, context: Context::default() }, json!({ "name": "x" })); + assert_eq!((app.job.is_none(), last_message(&app)), (true, "open a drive first")); +} diff --git a/apps/locallibrary/src/tui/screens.rs b/apps/locallibrary/src/tui/screens.rs index f9ddd693..f863cb1d 100644 --- a/apps/locallibrary/src/tui/screens.rs +++ b/apps/locallibrary/src/tui/screens.rs @@ -342,7 +342,9 @@ fn command_palette() { check(&mut app, "the library", &[": commands"], &[]); } -/// A plugin's command in the palette, its form and what it answers, from the plugins' manifests. +/// A plugin's command in the palette, its form and what it answers, and its operation in the +/// actions menu, its form starting with the selected track and its plan to confirm: all from the +/// plugins' manifests. #[test] fn plugins() { let Fixture { _dir, mut app, .. } = fixture(); @@ -351,7 +353,7 @@ fn plugins() { for c in ":answer the params".chars() { key(&mut app, KeyCode::Char(c)); } - check(&mut app, "a plugin's command in the palette", &["Answer the params", "Echo"], &[]); + check(&mut app, "a plugin's command in the palette", &["answer the params…", "Echo"], &[]); key(&mut app, KeyCode::Enter); check( &mut app, @@ -372,6 +374,28 @@ fn plugins() { &["Titles", "Title BPM", "Demo Track 1 128", "any other key closes"], &[], ); + key(&mut app, KeyCode::Esc); + + key(&mut app, KeyCode::Char('e')); + check(&mut app, "the actions menu with a plugin's operation", &["make a playlist…", "fetch cover art…"], &[]); + let Some(Modal::Menu(m)) = &app.modal else { panic!("no menu") }; + let echo = m.items.iter().position(|i| i.label == "make a playlist…").unwrap(); + for _ in 0..echo { + key(&mut app, KeyCode::Char('j')); + } + key(&mut app, KeyCode::Enter); + let id = app.loaded.as_ref().unwrap().library.tracks[app.visible[0]].id.to_string(); + check(&mut app, "a plugin's operation's form", &["Make a playlist", "tracks", &id, "required"], &[]); + for c in "Warmup\n".chars() { + key(&mut app, if c == '\n' { KeyCode::Enter } else { KeyCode::Char(c) }); + } + wait(&mut app, |a| a.job.is_none()); + check( + &mut app, + "a plugin's plan to confirm", + &["Write to DEMO?", "Echo: make the playlist Warmup", "+ Warmup", "y write", "u takes this back"], + &[], + ); } #[test] diff --git a/apps/locallibrary/src/tui/ui.rs b/apps/locallibrary/src/tui/ui.rs index 54d3f69a..bee49e21 100644 --- a/apps/locallibrary/src/tui/ui.rs +++ b/apps/locallibrary/src/tui/ui.rs @@ -2469,7 +2469,7 @@ fn draw_form(f: &mut Frame, app: &mut App, area: Rect) { typed => spans.push(Span::styled(clip(typed, room), text())), } if field.required && field.text.is_empty() { - spans.push(Span::styled("required", faint())); + spans.push(Span::styled(if on { " required" } else { "required" }, faint())); } let line = Line::from(spans); lines.push(if on { line.style(theme::selected()) } else { line }); @@ -2675,8 +2675,20 @@ fn draw_confirm(f: &mut Frame, app: &mut App, area: Rect) { Span::styled(format!("{}: ", c.drive), bold(theme::PINK)), Span::styled(p.databases.join(" and "), text()), ])); + // A plugin's operation: what the plugin says it does first. + let by = super::plugins::planned_by(c.plan.operation()); + if let Some((by, said)) = by.zip(c.plan.summary().first()) { + lines.push(Line::from(vec![ + Span::styled(format!("{by}: "), bold(theme::CYAN)), + Span::styled(said.clone(), text()), + ])); + } lines.push(Line::raw("")); edit_lines(p, &mut lines); + // What's said about the plan as a whole (a plugin's warnings), besides its edits'. + for w in c.plan.warnings().iter().filter(|w| !p.warnings.contains(w)) { + lines.push(Line::styled(format!("⚠ {w}"), fg(theme::AMBER))); + } lines.push(Line::raw("")); lines.push(Line::styled("The drive is backed up first: u takes this back.", dim())); lines.push(Line::styled( From 56c64f27ef61ce37ff42970a2fc6f20b421d3e1f Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:46:12 -0400 Subject: [PATCH 3/8] plugins' fields in filters and sorts: tracks, tracks.query and the player's search take sound.bpm:>120 and -sound.bpm, and the player runs analyzers from the palette and shows their fields as columns it remembers --- apps/locallibrary/CHANGELOG.md | 6 +- apps/locallibrary/docs/editing.md | 4 +- apps/locallibrary/docs/player.md | 4 + apps/locallibrary/docs/plugins.md | 9 +- apps/locallibrary/src/collection_cmd.rs | 2 +- apps/locallibrary/src/main.rs | 14 +- apps/locallibrary/src/plugins.rs | 27 +++ apps/locallibrary/src/rpc/methods.rs | 6 +- apps/locallibrary/src/tui/app.rs | 113 ++++++++++- apps/locallibrary/src/tui/app/tests.rs | 3 + apps/locallibrary/src/tui/browse.rs | 17 +- apps/locallibrary/src/tui/drives.rs | 1 + apps/locallibrary/src/tui/edits.rs | 10 +- apps/locallibrary/src/tui/keymap.rs | 4 + apps/locallibrary/src/tui/palette.rs | 8 +- apps/locallibrary/src/tui/plugins.rs | 93 ++++++++- apps/locallibrary/src/tui/plugins/tests.rs | 110 ++++++++++- apps/locallibrary/src/tui/screens.rs | 31 +++ apps/locallibrary/src/tui/state.rs | 2 + apps/locallibrary/src/tui/state/tests.rs | 9 +- apps/locallibrary/src/tui/ui.rs | 44 ++++- apps/locallibrary/src/tui/ui/tests.rs | 28 +++ apps/locallibrary/tests/it/plugins.rs | 42 +++++ crates/locallibrary/src/api/editing.rs | 23 ++- crates/locallibrary/src/fields.rs | 58 +++++- crates/locallibrary/src/fields/tests.rs | 36 ++++ crates/locallibrary/src/plugin.rs | 7 + crates/locallibrary/src/query.rs | 208 +++++++++++++++++++-- crates/locallibrary/src/query/tests.rs | 166 ++++++++++++++++ 29 files changed, 1029 insertions(+), 56 deletions(-) diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index 15d94975..ab766915 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -217,8 +217,10 @@ Windows builds are experimental and untested on hardware (see Platforms in the R work; a plugin's errors keep their kinds (`not_found`, `io`...). In the player, plugins' commands and operations are in the palette and a drive's menu (operations in the actions menu too), asking for their params in a form drawn from their schemas; a command answers in a panel, - an operation's plan is confirmed, written and undone like any edit, and what plugins log goes - to its messages (`!`). See + an operation's plan is confirmed, written and undone like any edit, analyzers run from the + palette and their fields are columns to sort and search by, and what plugins log goes to its + messages (`!`). `tracks --filter`, `tracks --sort` and the sidecar's `tracks.query` take + analyzers' fields (`sound.bpm:>120`, `-sound.bpm`). See docs/plugins.md. The ones it ships: - `tracklist`: `playlist import` makes a playlist from a tracklist (M3U, CSV exports of streaming playlists, rekordbox text or XML, "Artist - Title" lines), and `tracklist match` diff --git a/apps/locallibrary/docs/editing.md b/apps/locallibrary/docs/editing.md index 6f473132..952c55df 100644 --- a/apps/locallibrary/docs/editing.md +++ b/apps/locallibrary/docs/editing.md @@ -198,6 +198,7 @@ track. `--filter` takes the same filter `tracks --filter` does: | `rating:>=4`, `plays:0`, `year:1999`, `time:>6:00`, `bitrate:<320` | numbers, ranges, comparisons | | `color:aqua`, `color:none`, `added:2026-09`, `added:>=2026-01-01` | colour, date added | | `playlist:Sets/Friday`, `tag:Genre/Deep`, `is:unfiled`, `is:unplayed`, `has:artwork` | membership, My Tags, state | +| `sound.bpm:>120`, `sound.key:8A` | what an analyzer [plugin](plugins.md) worked out (`tracks` and the player's search only) | `playlist:` takes a path or a unique name, as `--playlist` does, and a folder means everything in it; one that names no playlist is refused. So is a word before a colon that names no field @@ -205,7 +206,8 @@ it; one that names no playlist is refused. So is a word before a colon that name (`"re:zero"`) it's text. `tag:` reads the drive's My Tags wherever a filter goes, and `--history` finds lists in both databases. -Sort orders are lists of fields, highest first with a `-`: `key,bpm`, `-rating,title`. +Sort orders are lists of fields, highest first with a `-`: `key,bpm`, `-rating,title`. `tracks +--sort` also takes a plugin's field (`-sound.bpm`); tracks without a value for it go last. ### When rekordbox exports again diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index 52eece1b..cc97703f 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -38,6 +38,10 @@ listening to your library the way the players see it: takes it back. An operation, or a command of a plugin that reads the drive, needs one plugged in, not a folder opened by path. What plugins log goes to the messages (`!`). See [Plugins](plugins.md). +- **Analyzers**: "analyze the tracks: …" in the palette (or a drive's `e` menu) runs a plugin's + analyzer over the drive's tracks in the background, those not analysed already. "show the + sound.bpm column" adds a column of what it worked out (remembered for the next run), sorted by + a click on its header like any column, and the search box filters by it: `sound.bpm:>120`. - **Problems**: ✖ marks a track whose audio file isn't on the drive, ≈ one the drive holds another copy of. Browse › Problems lists them. - **Playback** of MP3, AAC/ALAC (`.m4a`), FLAC, WAV and AIFF, with a queue built from the list you diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index a50d4374..c773e414 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -44,14 +44,15 @@ with each other are both left out. - **Analyzers** run with `locallibrary analyze [LIBRARY] [--plugin ID] [--analyzer NAME]`, which works out each track's values once (again only for a changed audio file) and keeps them in the store. `locallibrary track LIBRARY --track ID` shows them under the track's details, each as - `ID.FIELD` (`sound.bpm`). + `ID.FIELD` (`sound.bpm`), and `locallibrary tracks` filters and sorts by them (`--filter + "sound.bpm:>120" --sort -sound.bpm`), as the sidecar's `tracks.query` does. - **Export formats** are `locallibrary export LIBRARY --format FORMAT -o PATH`, with the plugin's options as `--option KEY=VALUE`. `--dry-run` lists what it would write (each file, what goes in, what the format can't hold), and overwriting a file is asked about first unless `--replace`. -- **In the player** (`locallibrary tui`), commands and operations are in the palette (`:`) and a - drive's menu, operations in the actions menu (`e`) too, with a form for their params (see [the - player](player.md)). +- **In the player** (`locallibrary tui`), commands, operations and analyzers are in the palette + (`:`) and a drive's menu, operations in the actions menu (`e`) too, with a form for their + params; analyzers' fields are columns to show, sort and search by (see [the player](player.md)). - **Settings**: `locallibrary plugin settings ID` shows them, `plugin settings ID KEY VALUE` sets one and `plugin settings ID KEY --reset` puts it back to its default. They're kept in the store (`plugins/ID/settings.json`). diff --git a/apps/locallibrary/src/collection_cmd.rs b/apps/locallibrary/src/collection_cmd.rs index 339c7337..42591c30 100644 --- a/apps/locallibrary/src/collection_cmd.rs +++ b/apps/locallibrary/src/collection_cmd.rs @@ -114,7 +114,7 @@ pub fn run(args: CollectionArgs, store: &Option, out: Output, ctx: &Ctx if let Some(f) = &filter { let f = Filter::parse(f)?; let tags = f.uses_tags().then(|| c.tags_by_file()); - let ctx = api::filter_context(lib, &f, tags.as_ref())?; + let ctx = api::filter_context(lib, &f, tags.as_ref(), None)?; tracks.retain(|t| f.matches(t, &ctx)); } if let Some(s) = &sort { diff --git a/apps/locallibrary/src/main.rs b/apps/locallibrary/src/main.rs index 984813b2..eb9cb665 100644 --- a/apps/locallibrary/src/main.rs +++ b/apps/locallibrary/src/main.rs @@ -566,6 +566,7 @@ macro_rules! filter_help { const TRACKS_LONG_HELP: &str = concat!( filter_help!(), " + sound.bpm:>120 sound.key:8A what an analyzer plugin worked out (`locallibrary analyze`); --sort takes them too Examples: locallibrary tracks -s \"daft punk\" @@ -603,7 +604,7 @@ struct TracksArgs { /// Only tracks matching a filter, e.g. "bpm:120-128 key:8A~ rating:>=4" (syntax below) #[arg(long, short, allow_hyphen_values = true)] filter: Option, - /// Order, e.g. key,bpm or -rating (title, artist, album, genre, label, key, bpm, rating, color, year, plays, added, time, bitrate, path) + /// Order, e.g. key,bpm or -rating (title, artist, album, genre, label, key, bpm, rating, color, year, plays, added, time, bitrate, path, or a plugin's field like sound.bpm) #[arg(long, allow_hyphen_values = true)] sort: Option, /// Show at most this many @@ -1185,16 +1186,11 @@ fn run(cli: Cli, out: Output, contributed: Option) -> R // --search is free text in the filter. let filter: Vec = a.search.as_deref().map(search_terms).into_iter().chain(a.filter).collect(); let filter = (!filter.is_empty()).then(|| filter.join(" ")); + let fields = plugins::Installed::with(diff_cmd::existing_store(&cli.store)?); let reads = each_drive::read(a.drive, |drive| { let (filter, playlist, sort) = (filter.clone(), a.playlist.clone(), a.sort.clone()); - api::tracks_query(&api::TracksQuery { - library: drive.into(), - filter, - playlist, - sort, - offset: 0, - limit: a.limit, - }) + let req = api::TracksQuery { library: drive.into(), filter, playlist, sort, offset: 0, limit: a.limit }; + api::tracks_query_with(api::LibraryCache::none(), &req, Some(fields.source())) })?; out.page(); // Several drives' TSV or CSV is one listing, for the program reading it. diff --git a/apps/locallibrary/src/plugins.rs b/apps/locallibrary/src/plugins.rs index c3c605a9..759c6e5e 100644 --- a/apps/locallibrary/src/plugins.rs +++ b/apps/locallibrary/src/plugins.rs @@ -307,6 +307,33 @@ impl Reads for SidecarReads { } } +/// The analyzers installed and what they worked out, for filters and sorts naming their fields +/// (`sound.bpm:>120`): kept in `store`. Plugins that were refused have none. +pub struct Installed { + store: Option, + analyzers: Vec, + declared: locallibrary::fields::Declared, +} + +impl Installed { + pub fn with(store: Option) -> Installed { + let host = host().ok(); + Installed { + store, + analyzers: host.map(Plugins::analyzers).unwrap_or_default(), + declared: host.map(Plugins::fields).unwrap_or_default(), + } + } + + pub fn source(&self) -> locallibrary::fields::Source<'_> { + locallibrary::fields::Source { + store: self.store.as_ref(), + analyzers: &self.analyzers, + declared: &self.declared, + } + } +} + /// A capability a plugin has, and what it lets the plugin do. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct Granted { diff --git a/apps/locallibrary/src/rpc/methods.rs b/apps/locallibrary/src/rpc/methods.rs index cc157924..b498a5ff 100644 --- a/apps/locallibrary/src/rpc/methods.rs +++ b/apps/locallibrary/src/rpc/methods.rs @@ -482,7 +482,11 @@ fn build() -> Registry { r.read("drive.library", |_, req| Ok(api::drive_library_cached(&LIBRARIES, &req)?)).quick(); r.read("playlists.tree", |_, req| Ok(api::playlists_tree(&LIBRARIES, &req)?)).quick(); r.read("tracks.get", |_, req| Ok(api::tracks_get(&LIBRARIES, &req)?)).quick(); - r.read("tracks.query", |_, req| Ok(api::tracks_query_cached(&LIBRARIES, &req)?)).quick(); + r.read("tracks.query", |c, req| { + let fields = crate::plugins::Installed::with(c.existing()?); + Ok(api::tracks_query_with(&LIBRARIES, &req, Some(fields.source()))?) + }) + .quick(); r.read("track.analysis", |_, req| Ok(api::track_analysis_cached(&LIBRARIES, &req)?)).quick(); r.read("tracks.analysis", |_, req| Ok(api::tracks_analysis(&LIBRARIES, &req)?)).quick(); r.read("track.fields", |c, req| { diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs index 685c08f7..3339882d 100644 --- a/apps/locallibrary/src/tui/app.rs +++ b/apps/locallibrary/src/tui/app.rs @@ -13,9 +13,10 @@ use std::time::{Duration, Instant}; use locallibrary::anlz::{self, PhraseKind, TrackAnalysis}; use locallibrary::api; use locallibrary::error::panic_text; +use locallibrary::fields::TrackValues; use locallibrary::model::normalize; use locallibrary::op::AnyPlanned; -use locallibrary::query::{Filter, SortField, SortKey}; +use locallibrary::query::{FieldName, Filter, SortField, SortKey}; use locallibrary::sets::Set; use locallibrary::store::{LibraryRecord, SnapshotInfo}; use locallibrary::watch::{VolumeWatcher, WatchEvent}; @@ -148,6 +149,18 @@ pub enum MyTags { Failed(String), } +/// What the analyzers installed worked out for the drive's tracks (see +/// [`locallibrary::fields::TrackValues`]), for their columns, filters and sorts: read in the +/// background the first time one asks. +#[derive(Default)] +pub enum Fields { + #[default] + Unread, + Reading, + Read(Arc), + Failed(String), +} + /// A drive's library, loaded for browsing. pub struct Loaded { pub device: Device, @@ -165,9 +178,10 @@ pub struct Loaded { /// Each category's values, worked out when Browse is first opened. groups: FxHashMap>, /// Each track's place under a sort field, worked out when first sorted by it. - ranks: Vec<(SortField, Vec)>, + pub(super) ranks: Vec<(SortField, Vec)>, pub problems: Problems, pub my_tags: MyTags, + pub fields: Fields, /// The store's sets, for Browse › Sets: `None` until first read (see `App::read_sets`). sets: Option>, /// The drive went away: what's shown may be out of date, and can't be played or edited. @@ -197,6 +211,7 @@ impl Loaded { ranks: Vec::new(), problems: Problems { missing: None, copy_of, copies }, my_tags: MyTags::Unread, + fields: Fields::Unread, sets: None, stale: false, }; @@ -260,13 +275,25 @@ impl Loaded { let i = match self.ranks.iter().position(|(f, _)| *f == field) { Some(i) => i, None => { - self.ranks.push((field, browse::ranks(&self.library, field))); + let ranks = match (field, &self.fields) { + (SortField::Field(name), Fields::Read(v)) => browse::field_ranks(&self.library, v, name), + _ => browse::ranks(&self.library, field), + }; + self.ranks.push((field, ranks)); self.ranks.len() - 1 } }; &self.ranks[i].1 } + /// What the analyzers worked out, once read. + pub fn values(&self) -> Option<&Arc> { + match &self.fields { + Fields::Read(v) => Some(v), + _ => None, + } + } + /// The tracks of the list shown (library indexes, in the list's order). pub fn base(&mut self) -> Vec { match self.node.clone() { @@ -702,6 +729,9 @@ pub enum Msg { Missing(u64, Vec), /// The drive's My Tags, for the library load with this generation. MyTags(u64, Result>, String>), + /// What the analyzers worked out for the drive's tracks, for the library load with this + /// generation. + Fields(u64, Result, String>), /// Sort ranks and Browse's groups, for the library load with this generation. Indexed(u64, Box), /// The store's sets, for the library load with this generation. @@ -920,6 +950,8 @@ pub struct App { pub show_ratings: bool, /// The track list's My Tags column. pub show_tags_column: bool, + /// The track list's columns of plugins' fields (`sound.bpm`), in the order shown. + pub field_columns: Vec, pub details: Option
, pub details_state: ListState, /// The selected track and since when, to look up its details once the selection settles. @@ -1109,6 +1141,7 @@ impl App { show_crate: false, show_ratings: false, show_tags_column: false, + field_columns: Vec::new(), details: None, details_state: ListState::default(), details_seen: None, @@ -1206,6 +1239,7 @@ impl App { self.show_crate = s.crate_panel; self.show_ratings = s.ratings; self.show_tags_column = s.tags_column; + self.field_columns = s.field_columns.iter().map(|f| FieldName::new(f)).collect(); self.shuffle = s.shuffle; self.repeat = match s.repeat.as_deref() { Some("all") => Repeat::All, @@ -1239,7 +1273,7 @@ impl App { /// What to remember for next time: the drive, list, view, sort, volume, visualiser, panels, /// crate and queue. - fn saved_state(&self) -> Saved { + pub(super) fn saved_state(&self) -> Saved { let l = self.loaded.as_ref(); Saved { root: l.map(|l| l.root().to_path_buf()), @@ -1253,6 +1287,7 @@ impl App { crate_panel: self.show_crate, ratings: self.show_ratings, tags_column: self.show_tags_column, + field_columns: self.field_columns.iter().map(|f| f.as_str().to_string()).collect(), shuffle: self.shuffle, repeat: Some(format!("{:?}", self.repeat).to_lowercase()), quantize: Some(self.deck.quantize), @@ -1728,6 +1763,17 @@ impl App { self.refresh_visible(); } } + Msg::Fields(generation, r) => { + if let Some(l) = self.loaded.as_mut().filter(|l| l.generation == generation) { + l.fields = match r { + Ok(values) => Fields::Read(Arc::from(values)), + Err(e) => Fields::Failed(e), + }; + // Sorted by a field before its values were in: sorted again by them. + l.ranks.retain(|(f, _)| !matches!(f, SortField::Field(_))); + self.refresh_visible(); + } + } Msg::Sets(generation, r) => { let Some(l) = self.loaded.as_mut().filter(|l| l.generation == generation) else { return }; let sets = match r { @@ -1905,6 +1951,9 @@ impl App { if self.show_tags_column { self.read_my_tags(); } + if !self.field_columns.is_empty() { + self.read_fields(); + } } /// Whether rekordbox exported over edits recorded on the open drive, in the background: it @@ -2007,6 +2056,10 @@ impl App { if tags { self.read_my_tags(); } + let fields = filter.as_ref().is_some_and(|f| f.as_ref().is_ok_and(Filter::uses_fields)); + if fields || self.sort.is_some_and(|k| matches!(k.field, SortField::Field(_))) { + self.read_fields(); + } self.filter_note = None; let Some(l) = &mut self.loaded else { self.visible.clear(); @@ -2051,9 +2104,34 @@ impl App { None } }; + // What analyzers worked out, as `tracks --filter` reads it; until it's in, a + // plugin's field matches nothing. + let values = match &l.fields { + Fields::Read(v) => Some(v.clone()), + _ if !fields => None, + Fields::Reading | Fields::Unread => { + self.filter_note = Some("reading what analyzers worked out…".into()); + None + } + Fields::Failed(e) => { + self.filter_note = Some(format!("can't read what analyzers worked out: {e}")); + None + } + }; + let empty; + let values = match (fields, values.as_deref()) { + (false, _) => None, + (true, Some(v)) => Some(v), + // Read with no values yet: what the fields are, to check the filter against. + (true, None) => { + let declared = crate::plugins::host().map(|p| p.fields()).unwrap_or_default(); + empty = TrackValues { declared, ..TrackValues::default() }; + Some(&empty) + } + }; // Refused as `tracks --filter` refuses it: a `playlist:` naming nothing would match // no track, and negated, every one. - match api::filter_context(&lib, &f, tags.then(|| my_tags.as_deref().unwrap_or(&none))) { + match api::filter_context(&lib, &f, tags.then(|| my_tags.as_deref().unwrap_or(&none)), values) { Ok(ctx) => list.retain(|&i| f.matches(&lib.tracks[i], &ctx)), Err(e) => { self.filter_error = Some(e.to_string()); @@ -2067,6 +2145,12 @@ impl App { } if let Some(s) = self.sort { browse::sort_list(&mut list, l.rank(s.field), s.descending); + // Tracks without a value for a plugin's field go last, either way. + if let SortField::Field(name) = s.field { + let values = l.values(); + let id = |i: usize| l.library.tracks[i].id; + list.sort_by_key(|&i| values.is_none_or(|v| v.get(id(i), name.as_str()).is_none())); + } } self.visible = list; let len = self.visible.len(); @@ -2132,6 +2216,23 @@ impl App { self.spawn_or(job, move |e| Msg::MyTags(generation, Err(e))); } + /// Read what the analyzers installed worked out for the open drive's tracks in the + /// background, the first time a column, a filter or a sort needs it (again after analysing). + pub(super) fn read_fields(&mut self) { + let Some(l) = self.loaded.as_mut().filter(|l| matches!(l.fields, Fields::Unread)) else { return }; + l.fields = Fields::Reading; + let (root, library, generation) = (l.root().to_path_buf(), l.library.clone(), l.generation); + let store_root = self.store_root.clone(); + let job = move || { + let read = Device::open(&root).map(|device| { + let installed = crate::plugins::Installed::with(Store::open(&store_root).ok()); + Box::new(TrackValues::read(installed.source(), &device, &library)) + }); + Msg::Fields(generation, read.map_err(|e| e.to_string())) + }; + self.spawn_or(job, move |e| Msg::Fields(generation, Err(e))); + } + /// The selected track, as an index into the library. pub fn selected_track(&self) -> Option { self.track_state.selected().and_then(|s| self.visible.get(s)).copied() @@ -3207,6 +3308,8 @@ impl App { Act::ExportList => self.export_list(), Act::ExportXml => self.export_xml(), Act::Plugin(plugin, name) => self.plugin_here(plugin, name), + Act::Analyze(plugin, analyzer) => self.analyze(plugin, analyzer, self.action_drive()), + Act::FieldColumn(name) => self.toggle_field_column(name), Act::Details => { self.show_details = !self.show_details; if !self.show_details && self.focus == Focus::Details { diff --git a/apps/locallibrary/src/tui/app/tests.rs b/apps/locallibrary/src/tui/app/tests.rs index 2383b359..599cbaa2 100644 --- a/apps/locallibrary/src/tui/app/tests.rs +++ b/apps/locallibrary/src/tui/app/tests.rs @@ -2986,6 +2986,9 @@ fn what_is_remembered_is_what_the_next_run_starts_with(tc: hegel::TestCase) { for _ in 0..tc.draw(gs::integers::().max_value(20)) { press(&mut app, tc.draw(gs::sampled_from(KEYS))); } + // Plugins' columns, shown from the palette. + let shown = ["sound.bpm", "sound.key"].map(locallibrary::query::FieldName::new); + app.field_columns = shown[..tc.draw(gs::integers::().max_value(2))].to_vec(); let saved = app.saved_state(); state::save(&app.store_root, &saved); let read = state::load(&app.store_root); diff --git a/apps/locallibrary/src/tui/browse.rs b/apps/locallibrary/src/tui/browse.rs index df2e1b95..1925f3eb 100644 --- a/apps/locallibrary/src/tui/browse.rs +++ b/apps/locallibrary/src/tui/browse.rs @@ -1,9 +1,10 @@ //! Browsing a library: the sidebar tree (the playlists, plus CDJ-style categories under Browse), //! the track list's sort order, and which of its two modes the search box is in. +use locallibrary::fields::TrackValues; use locallibrary::harmony::{self, Camelot}; use locallibrary::model::{TrackColor, normalize}; -use locallibrary::query::{SortField, SortKey}; +use locallibrary::query::{FieldName, SortField, SortKey, compare_values}; use locallibrary::sets::Set; use locallibrary::{Library, Playlist, Track, paths}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -477,6 +478,7 @@ pub fn sort_label(f: SortField) -> &'static str { SortField::Time => "Time", SortField::Bitrate => "Bitrate", SortField::Path => "Path", + SortField::Field(name) => name.as_str(), } } @@ -499,6 +501,7 @@ pub fn sort_spec(k: SortKey) -> String { SortField::Time => "time", SortField::Bitrate => "bitrate", SortField::Path => "path", + SortField::Field(name) => name.as_str(), }; format!("{}{name}", if k.descending { "-" } else { "" }) } @@ -561,9 +564,21 @@ pub fn ranks(lib: &Library, field: SortField) -> Vec { SortField::Added => rank_by(t.len(), |a, b| t[a].date_added.cmp(&t[b].date_added)), SortField::Time => rank_by(t.len(), |a, b| t[a].duration_secs.cmp(&t[b].duration_secs)), SortField::Bitrate => rank_by(t.len(), |a, b| t[a].bitrate.cmp(&t[b].bitrate)), + // By its values, once they're read (see `field_ranks`). + SortField::Field(_) => vec![0; t.len()], } } +/// Every track's rank under a plugin's field `name`, by its `values` as +/// `locallibrary::query::sort_tracks_with` orders them (tracks without one share the last). +pub fn field_ranks(lib: &Library, values: &TrackValues, name: FieldName) -> Vec { + let value = |i: usize| values.get(lib.tracks[i].id, name.as_str()); + rank_by(lib.tracks.len(), |a, b| match (value(a), value(b)) { + (Some(x), Some(y)) => compare_values(x, y), + (x, y) => x.is_none().cmp(&y.is_none()), + }) +} + fn rank_by(n: usize, cmp: impl Fn(usize, usize) -> Ordering) -> Vec { let mut order: Vec = (0..n).collect(); order.sort_by(|&a, &b| cmp(a, b)); diff --git a/apps/locallibrary/src/tui/drives.rs b/apps/locallibrary/src/tui/drives.rs index b0b1fdeb..35eb7f07 100644 --- a/apps/locallibrary/src/tui/drives.rs +++ b/apps/locallibrary/src/tui/drives.rs @@ -323,6 +323,7 @@ impl App { Act::Undo => self.undo_on(root), Act::Eject => self.eject(vec![root]), Act::Plugin(plugin, name) => self.plugin_act(plugin, name, Some(root), None), + Act::Analyze(plugin, analyzer) => self.analyze(plugin, analyzer, Some(root)), act => self.perform(act, None), } } diff --git a/apps/locallibrary/src/tui/edits.rs b/apps/locallibrary/src/tui/edits.rs index ae5cdfc2..0fd66a2b 100644 --- a/apps/locallibrary/src/tui/edits.rs +++ b/apps/locallibrary/src/tui/edits.rs @@ -17,7 +17,7 @@ use locallibrary::edit::{Edit, EditOptions, EditPlan, TrackFields}; use locallibrary::model::TrackColor; use locallibrary::op::{AnyPlanned, Planned}; use locallibrary::progress::{Event, Phase, Progress}; -use locallibrary::query::{SortKey, TrackSelector}; +use locallibrary::query::{SortField, SortKey, TrackSelector}; use locallibrary::{CancelToken, Ctx, Error}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::widgets::ListState; @@ -235,7 +235,12 @@ pub fn menu_items(t: &Target) -> Vec { _ => "sort this playlist on the drive".into(), }, Action::SortList, - stale.clone().or(not_list).or(because(t.sort.is_none(), "sort the list first (b, or click a column)")), + stale.clone().or(not_list).or(because(t.sort.is_none(), "sort the list first (b, or click a column)")).or( + because( + t.sort.is_some_and(|k| matches!(k.field, SortField::Field(_))), + "a plugin's column can't sort the drive's playlists", + ), + ), ), item("N", format!("new playlist{into}…"), Action::NewPlaylist, stale.clone()), item("F", format!("new folder{into}…"), Action::NewFolder, stale.clone()), @@ -436,6 +441,7 @@ fn phase_name(p: Phase) -> &'static str { Phase::Delete => "deleting files", Phase::Verify => "verifying", Phase::Format => "formatting", + Phase::Analyze => "analyzing", // Mix identification's phases (edits don't run them), and any newer. _ => "working", } diff --git a/apps/locallibrary/src/tui/keymap.rs b/apps/locallibrary/src/tui/keymap.rs index b84bc73e..03dd0444 100644 --- a/apps/locallibrary/src/tui/keymap.rs +++ b/apps/locallibrary/src/tui/keymap.rs @@ -183,6 +183,10 @@ pub enum Act { /// A plugin's command, by the plugin's id and its name (from the palette and the drive /// menu, never a key). Plugin(&'static str, &'static str), + /// Run a plugin's analyzer (the plugin's id, the analyzer's name) on the drive's tracks. + Analyze(&'static str, &'static str), + /// Show or hide the column of a plugin's field. + FieldColumn(locallibrary::query::FieldName), } /// The help's sections. diff --git a/apps/locallibrary/src/tui/palette.rs b/apps/locallibrary/src/tui/palette.rs index f3c9c944..fa93063a 100644 --- a/apps/locallibrary/src/tui/palette.rs +++ b/apps/locallibrary/src/tui/palette.rs @@ -182,10 +182,12 @@ pub fn commands(view: View) -> Vec<(Act, Item)> { } impl App { - /// `:`: every action by name, then plugins' commands and operations. + /// `:`: every action by name, then plugins' commands, operations and analyzers, and in the + /// library the columns of their fields. pub(super) fn open_palette(&mut self) { - let (acts, items): (Vec, Vec) = - commands(self.view).into_iter().chain(self.plugin_items(self.action_drive().as_deref())).unzip(); + let acts = commands(self.view).into_iter().chain(self.plugin_items(self.action_drive().as_deref())); + let fields = (self.view == View::Library).then(|| self.field_items()).into_iter().flatten(); + let (acts, items): (Vec, Vec) = acts.chain(fields).unzip(); let mut p = Picker::new("Commands", items, Then::Act(acts)); p.empty = "no command has those letters"; self.modal = Some(Modal::Pick(p)); diff --git a/apps/locallibrary/src/tui/plugins.rs b/apps/locallibrary/src/tui/plugins.rs index c8577755..1e888fe2 100644 --- a/apps/locallibrary/src/tui/plugins.rs +++ b/apps/locallibrary/src/tui/plugins.rs @@ -12,6 +12,7 @@ use locallibrary::plugin::{Cli, CommandOutput}; use loclib_plugin::{Capability, Context, View}; use serde_json::Value; +use super::app::Fields; use super::app::{App, Level, View as Screen}; use super::drives::{Report, Tone}; use super::edits::{Modal, Target}; @@ -20,6 +21,7 @@ use super::keymap::Act; use super::palette::Item; use crate::plugin_cmd::view::{pairs, table}; use crate::plugins::params::properties; +use locallibrary::query::{FieldName, SortField}; /// A plugin's command or operation, and where it runs: the drive, and what's selected there (its /// context's drive is the drive's). @@ -166,14 +168,97 @@ impl App { root.map(|root| !self.extra_roots.iter().any(|r| r == root)) } - /// Every plugin's commands and operations, as the palette and the drive menu list them for - /// the drive at `root`: by title, with the plugin's name beside each. + /// Every plugin's commands, operations and analyzers, as the palette and the drive menu list + /// them for the drive at `root`: by title, with the plugin's name beside each. pub(super) fn plugin_items(&self, root: Option<&Path>) -> Vec<(Act, Item)> { - let all = offered(self.pluggable(root), true).into_iter(); - all.map(|o| (Act::Plugin(o.plugin, o.name), Item { unavailable: o.unavailable, ..Item::new(o.label, o.by) })) + let drive = self.pluggable(root); + let all = offered(drive, true).into_iter(); + let mut items: Vec<(Act, Item)> = all + .map(|o| (Act::Plugin(o.plugin, o.name), Item { unavailable: o.unavailable, ..Item::new(o.label, o.by) })) + .collect(); + let Ok(plugins) = crate::plugins::host() else { return items }; + // An analyzer reads the tracks' audio files: on a drive plugged in, as a command. + let why = match drive { + None => Some("open a drive first"), + Some(false) => Some("only on a plugged-in drive"), + Some(true) => None, + }; + for m in plugins.manifests() { + for a in &m.contributes.analyzers { + let label = format!("analyze the tracks: {}", named(&a.title)); + let item = Item { unavailable: why.map(String::from), ..Item::new(label, &m.name) }; + items.push((Act::Analyze(&m.id, &a.name), item)); + } + } + items + } + + /// A column for each plugin's field, as the palette lists them (in the library): shown or + /// hidden, with the plugin's name beside each. + pub(super) fn field_items(&self) -> Vec<(Act, Item)> { + let Ok(plugins) = crate::plugins::host() else { return Vec::new() }; + let all = plugins.manifests().flat_map(|m| m.contributes.analyzers.iter().map(move |a| (m, a))); + all.flat_map(|(m, a)| a.fields.keys().map(move |f| (m, FieldName::new(&format!("{}.{f}", m.id))))) + .map(|(m, name)| { + let doing = if self.field_columns.contains(&name) { "hide" } else { "show" }; + (Act::FieldColumn(name), Item::new(format!("{doing} the {} column", name.as_str()), &m.name)) + }) .collect() } + /// Show the column of a plugin's field, after the others, or hide it. + pub(super) fn toggle_field_column(&mut self, name: FieldName) { + match self.field_columns.iter().position(|f| *f == name) { + Some(i) => { + self.field_columns.remove(i); + } + None => { + self.field_columns.push(name); + self.read_fields(); + } + } + } + + /// Run a plugin's analyzer on the tracks of the drive at `root` in the background (esc stops + /// it), keeping what it works out in the store; then its columns, filters and sorts read it. + pub(super) fn analyze(&mut self, plugin: &'static str, analyzer: &'static str, root: Option) { + let Ok(plugins) = crate::plugins::host() else { return }; + let root = match (self.pluggable(root.as_deref()), root) { + (Some(true), Some(root)) => root, + (None, _) => return self.toast("open a drive first", Level::Info), + _ => return self.toast("only on a plugged-in drive", Level::Info), + }; + let Some(declared) = + plugins.manifest(plugin).ok().and_then(|m| m.contributes.analyzers.iter().find(|a| a.name == analyzer)) + else { + return; + }; + let title = named(&declared.title); + let store_root = self.store_root.clone(); + self.task("Analyzing".into(), move |ctx| { + let store = Store::open_or_init(&store_root)?; + let done = plugins.analyze(plugin, analyzer, &root.display().to_string(), None, &store, ctx)?; + Ok(Box::new(move |app: &mut App| { + let tracks = crate::output::plural(done.analyzed, "track"); + let mut said = format!("{title}: {tracks} analyzed, {} already", done.kept); + if done.failed > 0 { + said.push_str(&format!(", {} failed (! says why)", done.failed)); + } + let level = if done.failed > 0 && done.analyzed + done.kept == 0 { Level::Error } else { Level::Info }; + app.toast(said, level); + // Read again, with what it worked out. + if let Some(l) = app.loaded.as_mut().filter(|l| l.root() == root) { + l.fields = Fields::Unread; + l.ranks.retain(|(f, _)| !matches!(f, SortField::Field(_))); + } + app.refresh_visible(); + if !app.field_columns.is_empty() { + app.read_fields(); + } + })) + }); + } + /// What's selected, as a plugin's hook takes it: the tracks (by id) and the playlist (not a /// folder) the actions menu works on. fn context(&self, t: &Target) -> Context { diff --git a/apps/locallibrary/src/tui/plugins/tests.rs b/apps/locallibrary/src/tui/plugins/tests.rs index 9307a57b..49e1a9de 100644 --- a/apps/locallibrary/src/tui/plugins/tests.rs +++ b/apps/locallibrary/src/tui/plugins/tests.rs @@ -56,7 +56,7 @@ fn plugins_commands_are_in_the_palette() { assert_eq!(entry(&app, "answer the params…").1, Some("only on a plugged-in drive")); } -/// The drive menu ends with plugins' commands and operations, which run on that drive. +/// The drive menu ends with plugins' commands, operations and analyzers, which run on that drive. #[test] fn plugins_commands_are_in_the_drive_menu() { let (dir, root) = demo_drive(); @@ -69,7 +69,8 @@ fn plugins_commands_are_in_the_drive_menu() { press(&mut app, "e"); let p = picker(&app); assert!(p.title.starts_with("Drive · "), "{}", p.title); - assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("make a playlist…")); + assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("analyze the tracks: file size")); + assert!(p.items.iter().any(|i| i.label == "make a playlist…")); assert_eq!(entry(&app, "answer the params…").1, None); press(&mut app, "wait until\n"); assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Wait until cancelled")); @@ -385,3 +386,108 @@ fn an_operation_without_a_drive_isnt_planned() { app.run_plugin(Run { plugin: "echo", hook, root: None, context: Context::default() }, json!({ "name": "x" })); assert_eq!((app.job.is_none(), last_message(&app)), (true, "open a drive first")); } + +/// The demo drive open with the echo plugin, its second track's audio file made bigger than the +/// first's (the echo plugin's analyzer measures `echo.bytes`, a file's size), not analysed yet. +fn sized() -> (tempfile::TempDir, App) { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let app = app_on(&root); + let second = &app.loaded.as_ref().unwrap().library.tracks[1]; + std::fs::write(root.join(second.file_path.trim_start_matches('/')), "x".repeat(4000)).unwrap(); + (dir, app) +} + +/// The tracks shown, by id. +fn shown(app: &App) -> Vec { + let l = app.loaded.as_ref().unwrap(); + app.visible.iter().map(|&i| l.library.tracks[i].id).collect() +} + +/// Analysing from the palette: on a drive, in the background with esc to stop it, saying how it +/// went; without one it says so. What it worked out shows in the column asked for (from the +/// palette, saved for the next run), right away. +#[test] +fn analyzers_run_from_the_palette_and_fill_their_columns() { + let (_dir, _root) = demo_drive(); + let (mut app, _store) = blank(Screen::Library); + echo_plugin(_dir.path()); + press(&mut app, ":"); + assert_eq!(entry(&app, "analyze the tracks: file size").1, Some("open a drive first")); + press(&mut app, "\u{1b}"); + + let (_dir, mut app) = sized(); + let ids: Vec = app.loaded.as_ref().unwrap().library.tracks.iter().map(|t| t.id).collect(); + press(&mut app, ":show the echo.bytes column\n"); + assert_eq!(app.field_columns, [FieldName::new("echo.bytes")]); + wait(&mut app, |a| a.loaded.as_ref().unwrap().values().is_some()); + let s = screen(&mut app, 120, 30); + assert!(s.contains("echo.bytes") && !s.contains("4000"), "not analysed yet:\n{s}"); + press(&mut app, ":analyze the tracks: file\n"); + assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Analyzing")); + wait(&mut app, |a| a.job.is_none()); + assert_eq!(last_message(&app), "file size: 2 tracks analyzed, 0 already"); + wait(&mut app, |a| a.loaded.as_ref().unwrap().values().is_some_and(|v| v.tracks.len() == 2)); + let s = screen(&mut app, 120, 30); + assert!(s.contains("4000") && s.contains("500"), "{s}"); + press(&mut app, ":analyze the tracks: file\n"); + wait(&mut app, |a| a.job.is_none()); + assert_eq!(last_message(&app), "file size: 0 tracks analyzed, 2 already"); + assert_eq!(app.saved_state().field_columns, ["echo.bytes"]); + press(&mut app, ":hide the echo.bytes column\n"); + assert!(app.field_columns.is_empty() && !screen(&mut app, 120, 30).contains("echo.bytes")); + assert_eq!(ids.len(), 2); +} + +/// The search box filters by a plugin's field as `tracks --filter` does: a track without a value +/// isn't matched; a typo says which field it meant. +#[test] +fn the_search_box_filters_by_plugins_fields() { + let (_dir, mut app) = sized(); + let second = app.loaded.as_ref().unwrap().library.tracks[1].id; + let reading = |a: &App| matches!(a.loaded.as_ref().unwrap().fields, super::super::app::Fields::Reading); + press(&mut app, "/echo.bytes:>1000"); + assert_eq!(app.filter_note.as_deref(), Some("reading what analyzers worked out…")); + wait(&mut app, |a| !reading(a)); + assert!(app.visible.is_empty(), "nothing analysed: nothing matched"); + press(&mut app, "\u{1b}"); + press(&mut app, ":analyze the tracks: file\n"); + wait(&mut app, |a| a.job.is_none()); + press(&mut app, "/echo.bytes:>1000"); + wait(&mut app, |a| !reading(a)); + assert_eq!(shown(&app), [second]); + assert_eq!(app.filter_error, None); + press(&mut app, "\u{1b}/echo.byte:1"); + let said = app.filter_error.clone().unwrap_or_default(); + assert!(said.contains("did you mean echo.bytes?"), "{said}"); +} + +/// A plugin's column sorts by its values when its header's clicked, tracks without one last +/// either way; a playlist can't be sorted on the drive by it. +#[test] +fn plugins_columns_sort_by_their_values() { + let (_dir, mut app) = sized(); + press(&mut app, ":analyze the tracks: file\n"); + wait(&mut app, |a| a.job.is_none()); + let lib = app.loaded.as_ref().unwrap().library.clone(); + let (first, second) = (lib.tracks[0].id, lib.tracks[1].id); + // The first's file changes: it has no value now. + let root = app.loaded.as_ref().unwrap().root().to_path_buf(); + std::fs::write(root.join(lib.tracks[0].file_path.trim_start_matches('/')), "y").unwrap(); + press(&mut app, ":show the echo.bytes column\n"); + wait(&mut app, |a| a.loaded.as_ref().unwrap().values().is_some()); + let header = super::super::app::testing::spot(&mut app, "echo.bytes"); + super::super::app::testing::mouse(&mut app, super::super::app::testing::LEFT, header); + let field = SortField::Field(FieldName::new("echo.bytes")); + assert_eq!(app.sort.map(|k| (k.field, k.descending)), Some((field, false))); + assert_eq!(shown(&app), [second, first]); + assert!(screen(&mut app, 120, 30).contains("echo.bytes↑")); + super::super::app::testing::mouse(&mut app, super::super::app::testing::LEFT, header); + assert_eq!(shown(&app), [second, first], "the one without a value stays last"); + // Shown a playlist, it can't be sorted on the drive by it. + let mut t = app.target().unwrap(); + t.list = Some("Sets/Friday".into()); + let items = super::super::edits::menu_items(&t); + let sort = items.iter().find(|i| i.action == super::super::edits::Action::SortList).unwrap(); + assert_eq!(sort.unavailable.as_deref(), Some("a plugin's column can't sort the drive's playlists")); +} diff --git a/apps/locallibrary/src/tui/screens.rs b/apps/locallibrary/src/tui/screens.rs index f863cb1d..41e4eb19 100644 --- a/apps/locallibrary/src/tui/screens.rs +++ b/apps/locallibrary/src/tui/screens.rs @@ -398,6 +398,37 @@ fn plugins() { ); } +/// What an analyzer plugin worked out, as a column of the track list sorted by it, and the +/// search box filtering by it: from the plugins' manifests and the store. +#[test] +fn plugins_fields() { + let Fixture { _dir, mut app, root } = fixture(); + super::app::testing::echo_plugin(_dir.path()); + let lib = app.loaded.as_ref().unwrap().library.clone(); + std::fs::write(root.join(lib.tracks[1].file_path.trim_start_matches('/')), "x".repeat(4000)).unwrap(); + let store = Store::open(&app.store_root).unwrap(); + crate::plugins::host() + .unwrap() + .analyze("echo", "size", &root.display().to_string(), None, &store, &Ctx::silent()) + .unwrap(); + app.focus = Focus::Tracks; + for c in ":show the echo.bytes column".chars() { + key(&mut app, KeyCode::Char(c)); + } + key(&mut app, KeyCode::Enter); + app.sort = Some(locallibrary::query::SortKey { + field: locallibrary::query::SortField::Field(locallibrary::query::FieldName::new("echo.bytes")), + descending: true, + }); + wait(&mut app, |a| a.loaded.as_ref().unwrap().values().is_some()); + app.refresh_visible(); + check(&mut app, "a plugin's column, sorted by it", &["echo.bytes↓", "4000", "echo.bytes ↓"], &[]); + for c in "/echo.bytes:>1000".chars() { + key(&mut app, KeyCode::Char(c)); + } + check(&mut app, "the search box filtering by a plugin's field", &["/ echo.bytes:>1000", "1 match", "4000"], &[]); +} + #[test] fn edit_menu_and_confirmation() { let Fixture { _dir, mut app, root } = fixture(); diff --git a/apps/locallibrary/src/tui/state.rs b/apps/locallibrary/src/tui/state.rs index 0a0efb9a..9ec76fb7 100644 --- a/apps/locallibrary/src/tui/state.rs +++ b/apps/locallibrary/src/tui/state.rs @@ -39,6 +39,8 @@ pub struct Saved { pub ratings: bool, /// The track list's My Tags column. pub tags_column: bool, + /// The track list's columns of plugins' fields (`sound.bpm`), in order. + pub field_columns: Vec, pub shuffle: bool, /// `off`, `all` or `one`. pub repeat: Option, diff --git a/apps/locallibrary/src/tui/state/tests.rs b/apps/locallibrary/src/tui/state/tests.rs index 05f211b9..59513d73 100644 --- a/apps/locallibrary/src/tui/state/tests.rs +++ b/apps/locallibrary/src/tui/state/tests.rs @@ -63,7 +63,14 @@ fn what_is_saved_reads_back(tc: hegel::TestCase) { let saved = Saved { root: maybe(word()).map(|w| dir.path().join(format!("drives/d{w}"))), view: maybe(word()), - sort: tc.draw(gs::booleans()).then(|| SortKey { field: SortField::Bpm, descending: tc.draw(gs::booleans()) }), + sort: tc.draw(gs::booleans()).then(|| SortKey { + field: match tc.draw(gs::booleans()) { + true => SortField::Bpm, + false => SortField::Field(locallibrary::query::FieldName::new("sound.bpm")), + }, + descending: tc.draw(gs::booleans()), + }), + field_columns: (0..tc.draw(gs::integers::().max_value(2))).map(|i| format!("sound.f{i}")).collect(), search_all: tc.draw(gs::booleans()), volume: tc.draw(gs::booleans()).then(|| tc.draw(gs::integers::().max_value(150)) as f32 / 100.0), vis: maybe(word()), diff --git a/apps/locallibrary/src/tui/ui.rs b/apps/locallibrary/src/tui/ui.rs index bee49e21..78029b57 100644 --- a/apps/locallibrary/src/tui/ui.rs +++ b/apps/locallibrary/src/tui/ui.rs @@ -2,9 +2,10 @@ use locallibrary::api::{DriveStatus, FleetLibrary}; use locallibrary::compare::Relation; +use locallibrary::fields::FieldValue; use locallibrary::fleet::{Role, Standing}; use locallibrary::local_time; -use locallibrary::query::{SortField, SortKey}; +use locallibrary::query::{FieldName, SortField, SortKey}; use locallibrary::{DeviceInfo, Track}; use loclib_waveform::{CueLane, Detail, Overview, PhraseStrip, Pixels, Scope, Spectrum, bar_layout, cue_letter, vinyl}; use ratatui::Frame; @@ -613,6 +614,8 @@ enum Col { Bpm, Key, Time, + /// A plugin's field (`sound.bpm`). + Field(FieldName), } impl Col { @@ -629,6 +632,7 @@ impl Col { Col::Bpm => SortField::Bpm, Col::Key => SortField::Key, Col::Time => SortField::Time, + Col::Field(name) => SortField::Field(name), }) } @@ -651,6 +655,10 @@ impl Col { ] .into_iter() .find(|c| c.field() == Some(f)) + .or(match f { + SortField::Field(name) => Some(Col::Field(name)), + _ => None, + }) } fn width(self) -> Constraint { @@ -664,10 +672,29 @@ impl Col { Col::Plays | Col::Bpm => Constraint::Length(6), Col::Key => Constraint::Length(4), Col::Time => Constraint::Length(6), + // Room for its name and the sort's arrow. + Col::Field(name) => { + Constraint::Length(unicode_width::UnicodeWidthStr::width(name.as_str()).max(5) as u16 + 1) + } } } } +/// A plugin's field's value as its column shows it: numbers to two places at most, a list +/// joined. +fn field_cell(v: &FieldValue) -> Line<'static> { + match v { + FieldValue::Number(n) => { + let s = format!("{n:.2}"); + Line::styled(s.trim_end_matches('0').trim_end_matches('.').to_string(), fg(theme::AMBER)).right_aligned() + } + FieldValue::Integer(n) => Line::styled(n.to_string(), fg(theme::AMBER)).right_aligned(), + FieldValue::Boolean(b) => Line::styled(if *b { "yes" } else { "no" }, dim()), + FieldValue::String(s) => Line::styled(s.clone(), fg(theme::CYAN)), + FieldValue::Strings(all) => Line::styled(all.join(", "), fg(theme::CYAN)), + } +} + /// The columns that fit (ratings only when asked for), plus the sort column when it wouldn't /// show otherwise. fn columns(width: u16, sort: Option, ratings: bool, tags: bool) -> Vec { @@ -693,6 +720,15 @@ fn columns(width: u16, sort: Option, ratings: bool, tags: bool) -> Vec< v } +/// `cols` with the columns of the plugins' `fields` asked for, in order, before tempo, key and +/// time (one already there, as the sort column, staying where it is). +fn with_fields(mut cols: Vec, fields: &[FieldName]) -> Vec { + let at = cols.len().saturating_sub(3); + let more: Vec = fields.iter().map(|&f| Col::Field(f)).filter(|c| !cols.contains(c)).collect(); + cols.splice(at..at, more); + cols +} + /// The key the track list's keys are matched against, and whose file it is: the playing track's, /// or with nothing playing (or no key to go on), the selected track's. fn mix_from(app: &App) -> Option<(Camelot, String)> { @@ -778,6 +814,8 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect) { }; let cols = columns(area.width, app.sort, app.show_ratings, app.show_tags_column); + let cols = with_fields(cols, &app.field_columns); + let values = l.values().cloned(); let my_tags = match &l.my_tags { super::app::MyTags::Read(t) => Some(t.clone()), _ => None, @@ -884,6 +922,10 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect) { } Col::Key => Cell::from(Span::styled(format!(" {}", tr.key), fg(theme::CYAN))), Col::Time => Cell::from(Line::styled(clock(tr.duration_secs as f64), dim()).right_aligned()), + Col::Field(name) => { + let value = values.as_ref().and_then(|v| v.get(tr.id, name.as_str())); + Cell::from(value.map(field_cell).unwrap_or_default()) + } }); } let row = Row::new(cells); diff --git a/apps/locallibrary/src/tui/ui/tests.rs b/apps/locallibrary/src/tui/ui/tests.rs index fada0e44..faf920f1 100644 --- a/apps/locallibrary/src/tui/ui/tests.rs +++ b/apps/locallibrary/src/tui/ui/tests.rs @@ -1829,3 +1829,31 @@ fn the_drives_view_says_what_is_known(tc: hegel::TestCase) { assert_eq!(screen(&mut app, w, h).lines().count(), h as usize); } } + +/// Plugins' columns go before tempo, key and time, in the order asked for; one shown already as +/// the sort's stays where it is. +#[test] +fn plugins_columns_go_before_tempo_key_and_time() { + let (bpm, key) = (FieldName::new("sound.bpm"), FieldName::new("sound.key")); + let base = columns(80, None, false, false); + assert_eq!( + with_fields(base.clone(), &[bpm, key]), + [Col::Title, Col::Artist, Col::Field(bpm), Col::Field(key), Col::Bpm, Col::Key, Col::Time] + ); + assert_eq!(with_fields(base.clone(), &[]), base); + let sorted = columns(80, Some(SortKey { field: SortField::Field(key), descending: false }), false, false); + assert_eq!(sorted[2], Col::Field(key), "the sort column, not otherwise shown"); + assert_eq!(with_fields(sorted, &[bpm, key]).iter().filter(|c| **c == Col::Field(key)).count(), 1); +} + +/// A plugin's value as its column shows it: numbers to two places at most, a list joined. +#[test] +fn plugins_values_show_shortly() { + let text = |v: FieldValue| field_cell(&v).to_string(); + assert_eq!(text(FieldValue::Number(121.0)), "121"); + assert_eq!(text(FieldValue::Number(121.256)), "121.26"); + assert_eq!(text(FieldValue::Number(-8.5)), "-8.5"); + assert_eq!(text(FieldValue::Integer(4000)), "4000"); + assert_eq!(text(FieldValue::Boolean(true)), "yes"); + assert_eq!(text(FieldValue::Strings(vec!["deep".into(), "warm".into()])), "deep, warm"); +} diff --git a/apps/locallibrary/tests/it/plugins.rs b/apps/locallibrary/tests/it/plugins.rs index f84ed71e..9ccfbe1c 100644 --- a/apps/locallibrary/tests/it/plugins.rs +++ b/apps/locallibrary/tests/it/plugins.rs @@ -226,6 +226,48 @@ fn analyzers_fill_in_track_details() { assert!(without.json(&["track", "USB", "--track", "1"])[0].get("fields").is_none()); } +/// `tracks` filters and sorts by what analyzers worked out (`echo.bytes:>2000`, `-echo.bytes`), +/// as the sidecar's `tracks.query` does: a track without a value isn't matched and sorts last; a +/// field no analyzer declares, or a value its type can't hold, is refused. +#[test] +fn tracks_are_filtered_and_sorted_by_what_analyzers_worked_out() { + let env = env(); + let listed = env.json(&["tracks", "USB"]); + let file = |i: usize| { + env.volumes.join("USB").join(listed["tracks"][i]["file_path"].as_str().unwrap().trim_start_matches('/')) + }; + let ids = + |page: &Value| page["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect::>(); + let (first, second) = (listed["tracks"][0]["id"].as_u64().unwrap(), listed["tracks"][1]["id"].as_u64().unwrap()); + // Not analysed yet: no track has a value. + assert_eq!(env.json(&["tracks", "USB", "-f", "echo.bytes:>0"])["total"], 0); + std::fs::write(file(1), "x".repeat(4000)).unwrap(); + env.ok(&["analyze", "USB", "--analyzer", "size"]); + assert_eq!(ids(&env.json(&["tracks", "USB", "-f", "echo.bytes:>2000"])), [second]); + assert_eq!(ids(&env.json(&["tracks", "USB", "-f", "-echo.bytes:4000"])), [first]); + assert_eq!(ids(&env.json(&["tracks", "USB", "--sort", "-echo.bytes"])), [second, first]); + assert_eq!(ids(&env.json(&["tracks", "USB", "--sort", "echo.bytes"])), [first, second]); + let shown = env.ok(&["tracks", "USB", "-f", "echo.bytes:>2000", "--columns", "id"]); + assert_eq!(shown.lines().filter(|l| l.trim() == second.to_string().as_str()).count(), 1, "{shown}"); + // A track whose file changed has no value: it isn't matched, and sorts last either way. + std::fs::write(file(0), "y".repeat(5000)).unwrap(); + assert_eq!(ids(&env.json(&["tracks", "USB", "-f", "echo.bytes:>0"])), [second]); + assert_eq!(ids(&env.json(&["tracks", "USB", "--sort", "-echo.bytes"])), [second, first]); + assert_eq!(ids(&env.json(&["tracks", "USB", "--sort", "echo.bytes"])), [second, first]); + let err = env.fails(1, &["tracks", "USB", "-f", "echo.byte:1"]); + assert!(err.contains("there's no field \"echo.byte\" (did you mean echo.bytes?)"), "{err}"); + assert!(env.fails(1, &["tracks", "USB", "-f", "echo.bytes:many"]).contains("it's a number")); + assert!(env.fails(1, &["tracks", "USB", "--sort", "echo.byte"]).contains("did you mean echo.bytes?")); + + // The sidecar's `tracks.query` the same. + let mut s = Rpc::start(env.command()); + let query = json!({ "drive": "USB", "filter": "echo.bytes:>0", "sort": "-echo.bytes" }); + let page = s.call(1, "tracks.query", query); + assert_eq!(ids(&page["result"]), [second], "{page}"); + let wrong = s.call(2, "tracks.query", json!({ "drive": "USB", "filter": "echo.byte:1" })); + assert_eq!(wrong["error"]["data"]["kind"], "invalid", "{wrong}"); +} + /// The sound plugin locallibrary ships measures each track's tempo, key and loudness from its /// audio, once, and `track` shows every field it declares as `sound.FIELD`. #[test] diff --git a/crates/locallibrary/src/api/editing.rs b/crates/locallibrary/src/api/editing.rs index c1307a34..26c32a07 100644 --- a/crates/locallibrary/src/api/editing.rs +++ b/crates/locallibrary/src/api/editing.rs @@ -12,6 +12,7 @@ use crate::anlz::{self, Mood, TrackAnalysis}; use crate::device::Device; use crate::edit::{self, Edit, EditOptions, EditPlan, EditReport, journal::JournalEntry}; use crate::error::{Action, Reason}; +use crate::fields::{Source, TrackValues}; use crate::harmony; use crate::model::{BpmRange, Library, Track, TrackRef}; use crate::op::{Operation, Planned, Probe, Touched, bytes}; @@ -555,10 +556,19 @@ pub fn tracks_query(req: &TracksQuery) -> Result { /// [`tracks_query`], with the library from `cache`. pub fn tracks_query_cached(cache: &LibraryCache, req: &TracksQuery) -> Result { + tracks_query_with(cache, req, None) +} + +/// [`tracks_query_cached`], with what analyzers worked out read from `fields` for a filter or +/// sort naming a plugin's field (`sound.bpm:>120`, `-sound.bpm`); without it, those are refused. +pub fn tracks_query_with(cache: &LibraryCache, req: &TracksQuery, fields: Option>) -> Result { let (d, cached, lib) = cache.read(&req.library)?; let filter = req.filter.as_deref().map(Filter::parse).transpose()?.unwrap_or_default(); + let keys = req.sort.as_deref().map(|s| query::parse_sort_with(s, fields.map(|f| f.declared))).transpose()?; let tags = if filter.uses_tags() { Some(cached.tags_by_track(&d)?) } else { None }; - let ctx = filter_context(&lib, &filter, tags.as_deref())?; + let sorted_by_field = keys.iter().flatten().any(|k| matches!(k.field, query::SortField::Field(_))); + let values = fields.filter(|_| filter.uses_fields() || sorted_by_field).map(|f| TrackValues::read(f, &d, &lib)); + let ctx = filter_context(&lib, &filter, tags.as_deref(), values.as_ref())?; let mut tracks: Vec<&Track> = match &req.playlist { Some(p) => { let pl = lib.find_playlist(p).ok_or_else(|| Error::NotFound(format!("playlist {p:?}")))?; @@ -566,8 +576,8 @@ pub fn tracks_query_cached(cache: &LibraryCache, req: &TracksQuery) -> Result filter.select(&ctx), }; - if let Some(s) = &req.sort { - query::sort_tracks(&mut tracks, &query::parse_sort(s)?); + if let Some(keys) = &keys { + query::sort_tracks_with(&mut tracks, keys, values.as_ref()); } let total = tracks.len(); let total_duration_secs = tracks.iter().map(|t| u64::from(t.duration_secs)).sum(); @@ -575,16 +585,19 @@ pub fn tracks_query_cached(cache: &LibraryCache, req: &TracksQuery) -> Result( library: &'a Library, filter: &Filter, tags: Option<&'a TagsByTrack>, + fields: Option<&'a TrackValues>, ) -> Result> { let mut ctx = QueryContext::new(library); ctx.tags = tags; + ctx.fields = fields; filter.check(&ctx)?; Ok(ctx) } diff --git a/crates/locallibrary/src/fields.rs b/crates/locallibrary/src/fields.rs index aa7858a8..6c215043 100644 --- a/crates/locallibrary/src/fields.rs +++ b/crates/locallibrary/src/fields.rs @@ -6,17 +6,23 @@ //! its values, and a changed file's are never read. Each analyzer of each plugin version has a //! folder, `/cache/fields////`. Readers say which versions //! they read: the plugin host's, those installed. +//! +//! Filters and sorts name a field by its plugin's id and its own (`sound.bpm`): a plugin's +//! analyzers never share a field name. [`TrackValues`] has a library's, for them. use std::path::{Path, PathBuf}; use indexmap::IndexMap; +use rustc_hash::FxHashMap; use crate::audio_cache::{CACHE_DIR, Cache, Kind}; +use crate::device::Device; +use crate::model::Library; use crate::store::Store; -use crate::{Error, Result}; +use crate::{Error, Result, paths}; -/// One field's value, of the types a plugin's manifest declares. -pub use loclib_plugin::FieldValue; +/// One field's value, and the types a plugin's manifest declares. +pub use loclib_plugin::{FieldType, FieldValue}; /// An analyzer's fields for one track, by name. pub type Fields = IndexMap; @@ -99,5 +105,51 @@ impl FieldStore { } } +/// The fields the analyzers installed declare, by name as filters and sorts name them +/// (`sound.bpm`), with their types, in the order the plugins declare them. +pub type Declared = IndexMap; + +/// Where what analyzers worked out is read for filters and sorts naming their fields: the store +/// (none: nothing was worked out), and the analyzers installed with the fields they declare. +#[derive(Clone, Copy)] +pub struct Source<'a> { + pub store: Option<&'a Store>, + pub analyzers: &'a [Analyzer], + pub declared: &'a Declared, +} + +/// What the analyzers installed worked out for a library's tracks, as filters and sorts read it. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TrackValues { + pub declared: Declared, + /// Each track's values by its id, then field (`sound.bpm`); a track with none is left out. + pub tracks: FxHashMap, +} + +impl TrackValues { + /// What `source` has for the tracks of `library`, the library of `device`. + pub fn read(source: Source<'_>, device: &Device, library: &Library) -> TrackValues { + let store = source.store.map(FieldStore::in_store).filter(|_| !source.analyzers.is_empty()); + let tracks = store.map_or_else(FxHashMap::default, |fields| { + let values = |t: &crate::model::Track| { + let audio = device.checked_path(&paths::key(&t.file_path)).ok()?; + let named: Fields = (fields.get(&audio, source.analyzers).into_iter()) + .flat_map(|(plugin, analyzers)| { + analyzers.into_values().flatten().map(move |(name, v)| (format!("{plugin}.{name}"), v)) + }) + .collect(); + (!named.is_empty()).then_some((t.id, named)) + }; + library.tracks.iter().filter_map(values).collect() + }); + TrackValues { declared: source.declared.clone(), tracks } + } + + /// Track `id`'s value of the field `name`, if it has one. + pub fn get(&self, id: u32, name: &str) -> Option<&FieldValue> { + self.tracks.get(&id)?.get(name) + } +} + #[cfg(test)] mod tests; diff --git a/crates/locallibrary/src/fields/tests.rs b/crates/locallibrary/src/fields/tests.rs index fee048e2..39af3845 100644 --- a/crates/locallibrary/src/fields/tests.rs +++ b/crates/locallibrary/src/fields/tests.rs @@ -1,6 +1,7 @@ use std::fs; use super::*; +use crate::device::Device; use filetime::FileTime; @@ -111,3 +112,38 @@ fn bad_fields_are_refused() { assert!(matches!(store.put(&dir.path().join("gone.mp3"), &tempo, &ok), Err(Error::NotFound(_)))); assert!(!store.root.exists(), "nothing was written"); } + +/// A library's values, named by plugin and field (`sound.bpm`), from the analyzers asked for and +/// by each track's audio file on the drive: a track whose file has none, or isn't there, has +/// none; with no store, or no analyzer, no track has any, and the fields are still declared. +#[test] +fn a_librarys_values_are_named_by_plugin_and_field() { + let dir = tempfile::tempdir().unwrap(); + let st = Store::open_or_init(dir.path().join("store")).unwrap(); + let store = FieldStore::in_store(&st); + let drive = dir.path().join("USB"); + let a = audio(&drive.join("Contents"), "a.mp3", b"first"); + audio(&drive.join("Contents"), "b.mp3", b"second"); + let tempo = analyzer("sound", "0.1.0", "tempo"); + store.put(&a, &tempo, &fields(&[("bpm", FieldValue::Number(120.0))])).unwrap(); + store.put(&a, &analyzer("other", "0.1.0", "x"), &fields(&[("bpm", FieldValue::Number(1.0))])).unwrap(); + let device = Device::open(&drive).unwrap(); + let track = |id, file: &str| loclib_model::test_util::track(id, "A", "T", file); + let library = Library { + tracks: vec![track(1, "/Contents/a.mp3"), track(2, "/Contents/b.mp3"), track(3, "/Contents/gone.mp3")], + ..Default::default() + }; + let declared: Declared = [("sound.bpm".to_string(), FieldType::Number)].into_iter().collect(); + let analyzers = [tempo]; + let source = Source { store: Some(&st), analyzers: &analyzers, declared: &declared }; + let values = TrackValues::read(source, &device, &library); + assert_eq!(values.declared, declared); + assert_eq!(values.tracks.keys().copied().collect::>(), [1]); + assert_eq!(values.get(1, "sound.bpm"), Some(&FieldValue::Number(120.0))); + assert_eq!(values.get(1, "other.bpm"), None, "not asked for"); + assert_eq!(values.get(2, "sound.bpm"), None); + for source in [Source { store: None, ..source }, Source { analyzers: &[], ..source }] { + let none = TrackValues::read(source, &device, &library); + assert!(none.tracks.is_empty() && none.declared == declared); + } +} diff --git a/crates/locallibrary/src/plugin.rs b/crates/locallibrary/src/plugin.rs index ebd18b81..0ed62fe3 100644 --- a/crates/locallibrary/src/plugin.rs +++ b/crates/locallibrary/src/plugin.rs @@ -187,6 +187,13 @@ impl Plugins { .collect() } + /// Every analyzer's fields, by name as filters and sorts name them (`sound.bpm`), with their + /// types: in the order the plugins, and their analyzers, declare them. + pub fn fields(&self) -> crate::fields::Declared { + let all = self.manifests().flat_map(|m| m.contributes.analyzers.iter().map(move |a| (m, a))); + all.flat_map(|(m, a)| a.fields.iter().map(move |(name, &kind)| (format!("{}.{name}", m.id), kind))).collect() + } + /// Whether the plugin is running now. Never waits. pub fn is_running(&self, plugin: &str) -> bool { self.slot(plugin).is_ok_and(|s| s.is_running()) diff --git a/crates/locallibrary/src/query.rs b/crates/locallibrary/src/query.rs index 31e64742..0214859f 100644 --- a/crates/locallibrary/src/query.rs +++ b/crates/locallibrary/src/query.rs @@ -20,10 +20,16 @@ //! | `tag` | has a My Tag (`Category/Tag` or just the tag's name) | //! | `is` | `unfiled` (in no playlist), `played`, `unplayed` | //! | `has` | `artwork`, `comment`, `key`, `analysis` | +//! | `PLUGIN.FIELD` (`sound.bpm`) | what an analyzer plugin worked out: a number, range or comparison for a number (`sound.bpm:>120`; `128` is 127.5 to 128.5, `128.4` is 128.35 to 128.45), words for text (`sound.key:8A`), `true` or `false` | //! //! A word before a colon that names no field (`gnere:house`) is refused rather than taken as text //! to look for: as text it would match no track, and negated, every one. Quoted, the colon is -//! text (`"re:zero"`), as it is in a link (`https://...`). +//! text (`"re:zero"`), as it is in a link (`https://...`). A plugin's field is refused unless an +//! analyzer installed declares it, and a filter naming one needs what analyzers worked out (see +//! [`crate::fields::TrackValues`]), as `tag:` needs My Tags. +//! +//! Sort orders name a plugin's field the same way (`-sound.bpm`), where its values are at hand +//! ([`parse_sort_with`]). use rustc_hash::{FxHashMap, FxHashSet}; use schemars::JsonSchema; @@ -31,6 +37,7 @@ use serde::{Deserialize, Serialize}; use std::cell::{OnceCell, RefCell}; use crate::device::Device; +use crate::fields::{Declared, FieldType, FieldValue, TrackValues}; use crate::harmony::{self, Camelot}; use crate::model::{Library, Playlist, Track, TrackColor, normalize}; use crate::plays::HistoryList; @@ -128,11 +135,10 @@ pub fn nearest<'a>(word: &str, names: impl IntoIterator) -> Opti .map(|(_, name)| name) } -/// The error for `word:value` where `word` names no field, with the field it most likely meant. -fn unknown_field(tok: &str, word: &str) -> Error { - let near = nearest(word, FIELDS.iter().map(|(name, _)| *name)) - .map(|name| format!(" (did you mean {name}?)")) - .unwrap_or_default(); +/// The error for `word:value` where `word` names no field, with the field (of `names`) it most +/// likely meant. +fn unknown_field<'a>(tok: &str, word: &str, names: impl IntoIterator) -> Error { + let near = nearest(word, names).map(|name| format!(" (did you mean {name}?)")).unwrap_or_default(); Error::invalid(format!( "can't read {tok:?}: there's no field {word:?}{near}; to look for the text, put it in quotes" )) @@ -241,6 +247,41 @@ enum Cond { Tag(Vec), Is(Vec), Has(Vec), + /// A plugin's field (`sound.bpm`): its value as a number condition, when it reads as one, and + /// as words. + Plugin { + name: String, + num: Option, + text: Vec, + }, +} + +/// `word` (before a colon) as a plugin's field: two names joined by a dot (`sound.bpm`), each +/// as plugin ids and field names are, in lowercase. +fn plugin_field(word: &str) -> Option { + let word = word.to_ascii_lowercase(); + let (plugin, field) = word.split_once('.')?; + let name = |s: &str| { + s.starts_with(|c: char| c.is_ascii_lowercase()) + && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + }; + (name(plugin) && name(field)).then_some(word) +} + +/// How far from a number typed (`128.4`) a value still matches it: half its last digit. +fn precision(typed: &str) -> f64 { + let decimals = typed.trim().split_once('.').map_or(0, |(_, d)| d.len()); + 0.5 / 10f64.powi(decimals.min(12) as i32) +} + +/// Whether `text` (normalised) has `word` (normalised) as whole words: `8a` is in `8a`, never in +/// `18a`. +fn has_words(text: &str, word: &str) -> bool { + text.match_indices(word).any(|(i, _)| { + let before = text[..i].chars().next_back().is_none_or(|c| !c.is_alphanumeric()); + let after = text[i + word.len()..].chars().next().is_none_or(|c| !c.is_alphanumeric()); + before && after + }) } #[derive(Debug, Clone, PartialEq)] @@ -330,7 +371,19 @@ impl Filter { // A word that names no field is a typo, not text to look for (see the module // docs); a time (`3:30`) or a link (`https://...`) is text. None if !f.is_empty() && f.chars().all(char::is_alphabetic) && !v.starts_with("://") => { - return Err(unknown_field(tok, f)); + return Err(unknown_field(tok, f, FIELDS.iter().map(|(name, _)| *name))); + } + // A plugin's field (`sound.bpm`), found once its values are at hand. + None if plugin_field(f).is_some() => { + let value = &v[1..]; + if value.is_empty() { + return Err(bad("nothing after the colon")); + } + let name = plugin_field(f).expect("one"); + let num = Num::parse(value, number, precision(value)); + let text = value.split('|').map(normalize).filter(|v| !v.is_empty()).collect(); + terms.push(Term { negate, field: None, cond: Cond::Plugin { name, num, text } }); + continue; } None => (None, tok), }, @@ -425,12 +478,41 @@ impl Filter { self.terms.iter().any(|t| matches!(t.cond, Cond::Tag(_))) } + /// Whether anything in the filter needs what analyzers worked out (a plugin's field). + pub fn uses_fields(&self) -> bool { + self.terms.iter().any(|t| matches!(t.cond, Cond::Plugin { .. })) + } + /// Refuse a filter `ctx` can't answer: one naming a playlist the library doesn't have, or - /// needing My Tags `ctx` wasn't given. Such a term would match no track, and negated, every - /// track. Selecting checks this; [`Filter::matches`] doesn't. + /// needing My Tags or analyzers' values `ctx` wasn't given, or a plugin's field no analyzer + /// declares, or a value its field can't hold. Such a term would match no track, and negated, + /// every track. Selecting checks this; [`Filter::matches`] doesn't. pub fn check(&self, ctx: &QueryContext<'_>) -> Result<()> { for term in &self.terms { match &term.cond { + Cond::Plugin { name, .. } if ctx.fields.is_none() => { + return Err(Error::invalid(format!( + "a {name}: term needs what analyzers worked out, and none was read" + ))); + } + Cond::Plugin { name, num, text } => { + let declared = &ctx.fields.expect("given").declared; + let Some(&kind) = declared.get(name) else { + return Err(unknown_field(name, name, declared.keys().map(String::as_str))); + }; + let bad = |what: &str| Err(Error::invalid(format!("can't read the {name} term: {what}"))); + let switch = |t: &String| ["true", "false", "yes", "no"].contains(&t.as_str()); + match kind { + FieldType::Number | FieldType::Integer if num.is_none() => { + return bad("it's a number: give one, a range or a comparison (>120, 120-128)"); + } + FieldType::Boolean if text.is_empty() || !text.iter().all(switch) => { + return bad("it's true or false"); + } + _ if text.is_empty() && num.is_none() => return bad("nothing to look for"), + _ => {} + } + } Cond::Playlist(specs) => { if let Some(s) = specs.iter().find(|s| ctx.library.find_playlist(s).is_none()) { return Err(Error::NotFound(format!("playlist {s:?}"))); @@ -515,6 +597,23 @@ impl Term { "key" => !t.key.trim().is_empty(), _ => !t.analyze_path.is_empty(), }), + Cond::Plugin { name, num, text } => { + let words = |s: &str| { + let s = normalize(s); + text.iter().any(|w| has_words(&s, w)) + }; + match ctx.fields.and_then(|f| f.get(t.id, name)) { + None => false, + Some(FieldValue::Integer(v)) => num.is_some_and(|n| n.test(*v as f64)), + Some(FieldValue::Number(v)) => num.is_some_and(|n| n.test(*v)), + Some(FieldValue::Boolean(b)) => { + let says = |w: &String| matches!(w.as_str(), "true" | "yes") == *b; + text.iter().any(|w| ["true", "false", "yes", "no"].contains(&w.as_str()) && says(w)) + } + Some(FieldValue::String(s)) => words(s), + Some(FieldValue::Strings(all)) => all.iter().any(|s| words(s)), + } + } } } } @@ -524,6 +623,8 @@ pub struct QueryContext<'a> { pub library: &'a Library, /// My Tags by lowercase file key (see [`crate::paths::key`]), as `Category/Tag`. pub tags: Option<&'a FxHashMap>>, + /// What analyzers worked out for the tracks, for plugins' fields (`sound.bpm`). + pub fields: Option<&'a TrackValues>, /// The drive's history lists, from both its databases (see [`DriveContext`]); without them, /// the library's own. history: Option<&'a [HistoryList]>, @@ -540,6 +641,7 @@ impl<'a> QueryContext<'a> { QueryContext { library, tags: None, + fields: None, history: None, playlists: Default::default(), haystacks: Default::default(), @@ -664,6 +766,38 @@ pub enum SortField { Time, Bitrate, Path, + /// A plugin's field: tracks without a value for it go last. + Field(FieldName), +} + +/// A plugin's field as a sort names it (`sound.bpm`), kept for good: sort keys stay `Copy` +/// (front ends pass them about), and there are few names to keep, the fields the plugins declare +/// and those a saved sort names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, JsonSchema)] +#[serde(transparent)] +pub struct FieldName(&'static str); + +impl FieldName { + pub fn new(name: &str) -> FieldName { + static NAMES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let mut names = NAMES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let kept = names.iter().find(|n| **n == name).copied().unwrap_or_else(|| { + let n: &'static str = Box::leak(name.to_string().into_boxed_str()); + names.push(n); + n + }); + FieldName(kept) + } + + pub fn as_str(self) -> &'static str { + self.0 + } +} + +impl<'de> Deserialize<'de> for FieldName { + fn deserialize>(d: D) -> std::result::Result { + String::deserialize(d).map(|s| FieldName::new(&s)) + } } /// One level of a sort order, e.g. `-rating` (highest first). @@ -676,6 +810,11 @@ pub struct SortKey { /// Parse `key,bpm` or `-rating,title` (a leading `-` sorts that level highest first). pub fn parse_sort(s: &str) -> Result> { + parse_sort_with(s, None) +} + +/// [`parse_sort`], taking the plugins' fields `declared` (`-sound.bpm`) too, when they're given. +pub fn parse_sort_with(s: &str, declared: Option<&Declared>) -> Result> { let keys: Vec = s .split(',') .map(str::trim) @@ -701,6 +840,15 @@ pub fn parse_sort(s: &str) -> Result> { "time" | "length" | "duration" => SortField::Time, "bitrate" => SortField::Bitrate, "path" | "file" => SortField::Path, + other if plugin_field(other).is_some() => match declared { + Some(d) if d.contains_key(other) => SortField::Field(FieldName::new(other)), + Some(d) => return Err(unknown_field(other, other, d.keys().map(String::as_str))), + None => { + return Err(Error::invalid(format!( + "can't sort by {other:?} here: a plugin's field sorts track listings only" + ))); + } + }, other => { return Err(Error::invalid(format!( "can't sort by {other:?} (title, artist, album, genre, label, key, bpm, rating, color, year, plays, added, time, bitrate, path)" @@ -716,6 +864,26 @@ pub fn parse_sort(s: &str) -> Result> { Ok(keys) } +/// How two values of a plugin's field compare: numbers by size, text by its words, a list by its +/// first, `false` before `true`. +pub fn compare_values(a: &FieldValue, b: &FieldValue) -> std::cmp::Ordering { + let number = |v: &FieldValue| match v { + FieldValue::Integer(n) => Some(*n as f64), + FieldValue::Number(n) => Some(*n), + _ => None, + }; + let text = |v: &FieldValue| match v { + FieldValue::String(s) => normalize(s), + FieldValue::Strings(all) => all.first().map(|s| normalize(s)).unwrap_or_default(), + FieldValue::Boolean(b) => b.to_string(), + n => number(n).map(|n| n.to_string()).unwrap_or_default(), + }; + match (number(a), number(b)) { + (Some(x), Some(y)) => x.total_cmp(&y), + _ => text(a).cmp(&text(b)), + } +} + fn compare(a: &Track, b: &Track, field: SortField) -> std::cmp::Ordering { let text = |x: &str, y: &str| normalize(x).cmp(&normalize(y)); match field { @@ -734,16 +902,34 @@ fn compare(a: &Track, b: &Track, field: SortField) -> std::cmp::Ordering { SortField::Time => a.duration_secs.cmp(&b.duration_secs), SortField::Bitrate => a.bitrate.cmp(&b.bitrate), SortField::Path => crate::paths::key(&a.file_path).cmp(&crate::paths::key(&b.file_path)), + // By its values (see `sort_tracks_with`). + SortField::Field(_) => std::cmp::Ordering::Equal, } } /// Stable sort: tracks equal under every key keep their order. pub fn sort_tracks(tracks: &mut [&Track], keys: &[SortKey]) { + sort_tracks_with(tracks, keys, None); +} + +/// [`sort_tracks`] with what analyzers worked out, for keys naming a plugin's field: tracks +/// without a value for it go last, either way (all of them, without `values`). +pub fn sort_tracks_with(tracks: &mut [&Track], keys: &[SortKey], values: Option<&TrackValues>) { tracks.sort_by(|a, b| { keys.iter() .map(|k| { - let o = compare(a, b, k.field); - if k.descending { o.reverse() } else { o } + let directed = |o: std::cmp::Ordering| if k.descending { o.reverse() } else { o }; + match k.field { + // Those without a value last, whichever way it's sorted. + SortField::Field(name) => { + let value = |t: &Track| values.and_then(|v| v.get(t.id, name.as_str())); + match (value(a), value(b)) { + (Some(x), Some(y)) => directed(compare_values(x, y)), + (x, y) => x.is_none().cmp(&y.is_none()), + } + } + field => directed(compare(a, b, field)), + } }) .find(|o| o.is_ne()) .unwrap_or(std::cmp::Ordering::Equal) diff --git a/crates/locallibrary/src/query/tests.rs b/crates/locallibrary/src/query/tests.rs index a965f1dd..cdb917f8 100644 --- a/crates/locallibrary/src/query/tests.rs +++ b/crates/locallibrary/src/query/tests.rs @@ -271,3 +271,169 @@ fn history_lists_are_selected_by_exact_name_first() { assert_eq!(history("history 001").unwrap(), [2]); assert!(history("History 001").is_err(), "two match ignoring case"); } + +/// What analyzers worked out for the test library: `sound.bpm` (a number) for the first two +/// tracks, `sound.key` (text) for all three, `sound.vocal` (a switch) and `sound.moods` (a list) +/// for the first; `sound.beats` (whole numbers) is declared but none has it. +fn values() -> TrackValues { + let declared = [ + ("sound.bpm", FieldType::Number), + ("sound.beats", FieldType::Integer), + ("sound.key", FieldType::String), + ("sound.vocal", FieldType::Boolean), + ("sound.moods", FieldType::Strings), + ]; + let declared = declared.into_iter().map(|(n, t)| (n.to_string(), t)).collect(); + let fields = |pairs: Vec<(&str, FieldValue)>| pairs.into_iter().map(|(n, v)| (n.to_string(), v)).collect(); + let tracks = [ + ( + 1, + fields(vec![ + ("sound.bpm", FieldValue::Number(121.04)), + ("sound.key", FieldValue::String("8A".into())), + ("sound.vocal", FieldValue::Boolean(true)), + ("sound.moods", FieldValue::Strings(vec!["deep house".into(), "warm".into()])), + ]), + ), + (2, fields(vec![("sound.bpm", FieldValue::Number(127.6)), ("sound.key", FieldValue::String("11A".into()))])), + (3, fields(vec![("sound.key", FieldValue::String("1A".into()))])), + ]; + TrackValues { declared, tracks: tracks.into_iter().collect() } +} + +/// The tracks a filter naming plugins' fields selects, with [`values`]. +fn ids_with_values(f: &str) -> Result> { + let (lib, values) = (lib(), values()); + let ctx = QueryContext { fields: Some(&values), ..QueryContext::new(&lib) }; + let filter = Filter::parse(f)?; + filter.check(&ctx)?; + Ok(filter.select(&ctx).iter().map(|t| t.id).collect()) +} + +/// A plugin's field matches its values: numbers by condition (an exact one to the precision +/// typed), text by whole words, a switch by true or false, a list by any of it; a track without +/// a value matches nothing, and negated, everything. +#[test] +fn plugins_fields_filter_by_their_values() { + let ids = |f: &str| ids_with_values(f).unwrap(); + assert_eq!(ids("sound.bpm:>120"), [1, 2]); + assert_eq!(ids("sound.bpm:>=125"), [2]); + assert_eq!(ids("sound.bpm:120-122"), [1]); + assert_eq!(ids("sound.bpm:121"), [1], "121.04 is 121"); + assert_eq!(ids("sound.bpm:128"), [2], "127.6 is 128"); + assert_eq!(ids("sound.bpm:121.0"), [1]); + assert_eq!(ids("sound.bpm:127.6"), [2], "to the tenth typed"); + assert!(ids("sound.bpm:121.1").is_empty(), "121.04 isn't 121.1"); + assert_eq!(ids("-sound.bpm:>120"), [3], "no value: not matched, so negated it is"); + assert_eq!(ids("sound.key:8A"), [1]); + assert_eq!(ids("sound.key:1a"), [3], "a whole word: not 11A"); + assert_eq!(ids("sound.key:1A|11A"), [2, 3]); + assert_eq!(ids("Sound.Key:8a"), [1], "case doesn't matter"); + assert_eq!(ids("sound.vocal:true"), [1]); + assert_eq!(ids("sound.vocal:no"), Vec::::new()); + assert_eq!(ids("sound.moods:deep"), [1]); + assert_eq!(ids("sound.moods:warm"), [1]); + assert!(ids("sound.moods:dee").is_empty(), "words, not parts of them"); + assert!(ids("sound.beats:>0").is_empty(), "declared, but none has it"); + assert_eq!(ids("sound.bpm:>120 sound.key:8A"), [1]); + // Quoted, a dotted word before a colon is text. + assert!(ids("\"sound.bpm:>120\"").is_empty()); +} + +/// A plugin's field no analyzer declares, a value its type can't hold, nothing after the colon, +/// and a filter naming one with nothing analyzers worked out are refused, never matched. +#[test] +fn plugins_fields_are_refused_when_they_cant_be_answered() { + let refused = |f: &str| ids_with_values(f).unwrap_err().to_string(); + assert!(refused("sound.bpmm:>120").contains("did you mean sound.bpm?"), "{}", refused("sound.bpmm:>120")); + assert!(refused("other.bpm:1").contains("there's no field \"other.bpm\""), "{}", refused("other.bpm:1")); + assert!(refused("sound.bpm:fast").contains("it's a number"), "{}", refused("sound.bpm:fast")); + assert!(refused("sound.beats:x").contains("it's a number")); + assert!(refused("sound.vocal:maybe").contains("true or false")); + assert!(refused("sound.bpm:").contains("nothing after the colon")); + assert!(refused("sound.key:|").contains("nothing to look for")); + let lib = lib(); + let f = Filter::parse("sound.bpm:>120").unwrap(); + assert!(f.uses_fields() && !Filter::parse("bpm:>120").unwrap().uses_fields()); + let said = f.check(&QueryContext::new(&lib)).unwrap_err().to_string(); + assert!(said.contains("a sound.bpm: term needs what analyzers worked out"), "{said}"); +} + +/// A sort names a plugin's field where its values are given, and its declared fields: by value, +/// either way, tracks without one last; elsewhere it's refused. +#[test] +fn plugins_fields_sort_with_their_values() { + let (lib, values) = (lib(), values()); + let sorted = |s: &str| { + let keys = parse_sort_with(s, Some(&values.declared)).unwrap(); + let mut v: Vec<&Track> = lib.tracks.iter().rev().collect(); + sort_tracks_with(&mut v, &keys, Some(&values)); + v.iter().map(|t| t.id).collect::>() + }; + assert_eq!(sorted("sound.bpm"), [1, 2, 3]); + assert_eq!(sorted("-sound.bpm"), [2, 1, 3], "the track without one stays last"); + assert_eq!(sorted("sound.key"), [2, 3, 1], "text: 11a, 1a, 8a"); + assert_eq!(sorted("sound.beats,title"), [1, 2, 3], "none has it: the next key orders them"); + let keys = parse_sort_with("-sound.bpm", Some(&values.declared)).unwrap(); + assert_eq!(keys, [SortKey { field: SortField::Field(FieldName::new("sound.bpm")), descending: true }]); + let mut none: Vec<&Track> = lib.tracks.iter().rev().collect(); + sort_tracks(&mut none, &keys); + assert_eq!(none.iter().map(|t| t.id).collect::>(), [3, 2, 1], "without values: as it was"); + let said = parse_sort_with("sound.bpmm", Some(&values.declared)).unwrap_err().to_string(); + assert!(said.contains("did you mean sound.bpm?"), "{said}"); + let said = parse_sort("sound.bpm").unwrap_err().to_string(); + assert!(said.contains("a plugin's field sorts track listings only"), "{said}"); + // Kept in a saved sort by its name, and read back as the same. + let json = serde_json::to_string(&keys[0]).unwrap(); + assert_eq!(json, r#"{"field":{"field":"sound.bpm"},"descending":true}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), keys[0]); +} + +/// Values compare by number, then by text; a missing value isn't compared here. +#[test] +fn field_values_compare_numbers_then_text() { + use std::cmp::Ordering::*; + let (n, i, s) = (FieldValue::Number, FieldValue::Integer, |x: &str| FieldValue::String(x.into())); + assert_eq!(compare_values(&n(9.5), &i(10)), Less); + assert_eq!(compare_values(&i(10), &n(9.5)), Greater); + assert_eq!(compare_values(&s("b"), &s("A")), Greater); + assert_eq!(compare_values(&FieldValue::Boolean(false), &FieldValue::Boolean(true)), Less); + assert_eq!(compare_values(&FieldValue::Strings(vec!["a".into()]), &FieldValue::Strings(vec![])), Greater); +} + +/// Any values, some tracks without one: `sound.bpm:>T` selects exactly the tracks whose value is +/// above `T`, negated the rest; sorted by it either way, the tracks with one come in order and +/// those without last. +#[hegel::test(test_cases = 200)] +fn a_plugins_number_filters_and_sorts_as_its_values_say(tc: hegel::TestCase) { + use hegel::generators as gs; + let lib = lib(); + let mut values = values(); + values.tracks.clear(); + for t in &lib.tracks { + if tc.draw(gs::booleans()) { + let v = tc.draw(gs::sampled_from(&[60.0, 119.99, 120.0, 120.5, 128.0, 174.0][..])); + values.tracks.insert(t.id, [("sound.bpm".to_string(), FieldValue::Number(v))].into_iter().collect()); + } + } + let over = tc.draw(gs::sampled_from(&[0.0, 120.0, 120.25, 200.0][..])); + let value = |id: u32| match values.get(id, "sound.bpm") { + Some(FieldValue::Number(v)) => Some(*v), + _ => None, + }; + let ctx = QueryContext { fields: Some(&values), ..QueryContext::new(&lib) }; + let selected = |f: &str| Filter::parse(f).unwrap().select(&ctx).iter().map(|t| t.id).collect::>(); + let above: Vec = lib.tracks.iter().map(|t| t.id).filter(|&id| value(id).is_some_and(|v| v > over)).collect(); + let rest: Vec = lib.tracks.iter().map(|t| t.id).filter(|id| !above.contains(id)).collect(); + assert_eq!(selected(&format!("sound.bpm:>{over}")), above); + assert_eq!(selected(&format!("-sound.bpm:>{over}")), rest); + let descending = tc.draw(gs::booleans()); + let keys = [SortKey { field: SortField::Field(FieldName::new("sound.bpm")), descending }]; + let mut sorted: Vec<&Track> = lib.tracks.iter().collect(); + sort_tracks_with(&mut sorted, &keys, Some(&values)); + let got: Vec> = sorted.iter().map(|t| value(t.id)).collect(); + let with = got.iter().take_while(|v| v.is_some()).count(); + assert!(got[with..].iter().all(Option::is_none), "{got:?}"); + let ordered = got[..with].windows(2).all(|w| if descending { w[0] >= w[1] } else { w[0] <= w[1] }); + assert!(ordered, "{got:?}"); +} From 43686e235c660de7b7618cf9222df098037a504d Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:11:12 -0400 Subject: [PATCH 4/8] tui: plugins' settings in a form from the palette, filled as they're set and saving only what changed --- apps/locallibrary/CHANGELOG.md | 4 +- apps/locallibrary/docs/player.md | 3 + apps/locallibrary/docs/plugins.md | 3 +- apps/locallibrary/src/tui/app.rs | 1 + apps/locallibrary/src/tui/app/testing.rs | 26 ++++- apps/locallibrary/src/tui/form.rs | 61 +++++++--- apps/locallibrary/src/tui/keymap.rs | 2 + apps/locallibrary/src/tui/palette.rs | 6 +- apps/locallibrary/src/tui/plugins.rs | 84 ++++++++++++-- apps/locallibrary/src/tui/plugins/tests.rs | 123 +++++++++++++++++++++ apps/locallibrary/src/tui/screens.rs | 18 +++ apps/locallibrary/src/tui/ui.rs | 6 +- 12 files changed, 301 insertions(+), 36 deletions(-) diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index ab766915..c138ac47 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -218,8 +218,8 @@ Windows builds are experimental and untested on hardware (see Platforms in the R commands and operations are in the palette and a drive's menu (operations in the actions menu too), asking for their params in a form drawn from their schemas; a command answers in a panel, an operation's plan is confirmed, written and undone like any edit, analyzers run from the - palette and their fields are columns to sort and search by, and what plugins log goes to its - messages (`!`). `tracks --filter`, `tracks --sort` and the sidecar's `tracks.query` take + palette and their fields are columns to sort and search by, plugins' settings are a form in the + palette, and what plugins log goes to its messages (`!`). `tracks --filter`, `tracks --sort` and the sidecar's `tracks.query` take analyzers' fields (`sound.bpm:>120`, `-sound.bpm`). See docs/plugins.md. The ones it ships: - `tracklist`: `playlist import` makes a playlist from a tracklist (M3U, CSV exports of diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index cc97703f..c388db77 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -42,6 +42,9 @@ listening to your library the way the players see it: analyzer over the drive's tracks in the background, those not analysed already. "show the sound.bpm column" adds a column of what it worked out (remembered for the next run), sorted by a click on its header like any column, and the search box filters by it: `sound.bpm:>120`. +- **Plugins' settings**: "Cover art plugin settings…" in the palette opens a plugin's settings in + the same form, as they're set now; `enter` saves what changed, and a field left empty goes back + to its default. - **Problems**: ✖ marks a track whose audio file isn't on the drive, ≈ one the drive holds another copy of. Browse › Problems lists them. - **Playback** of MP3, AAC/ALAC (`.m4a`), FLAC, WAV and AIFF, with a queue built from the list you diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index c773e414..096788ad 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -52,7 +52,8 @@ with each other are both left out. `--replace`. - **In the player** (`locallibrary tui`), commands, operations and analyzers are in the palette (`:`) and a drive's menu, operations in the actions menu (`e`) too, with a form for their - params; analyzers' fields are columns to show, sort and search by (see [the player](player.md)). + params; analyzers' fields are columns to show, sort and search by, and each plugin's settings + are a form in the palette too (see [the player](player.md)). - **Settings**: `locallibrary plugin settings ID` shows them, `plugin settings ID KEY VALUE` sets one and `plugin settings ID KEY --reset` puts it back to its default. They're kept in the store (`plugins/ID/settings.json`). diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs index 3339882d..a700ab95 100644 --- a/apps/locallibrary/src/tui/app.rs +++ b/apps/locallibrary/src/tui/app.rs @@ -3310,6 +3310,7 @@ impl App { Act::Plugin(plugin, name) => self.plugin_here(plugin, name), Act::Analyze(plugin, analyzer) => self.analyze(plugin, analyzer, self.action_drive()), Act::FieldColumn(name) => self.toggle_field_column(name), + Act::PluginSettings(plugin) => self.plugin_settings(plugin), Act::Details => { self.show_details = !self.show_details; if !self.show_details && self.focus == Focus::Details { diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index 58cf698d..5cf4300e 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -477,8 +477,8 @@ pub fn hold_volume_polls(app: &mut App) { /// loclib-plugin's echo plugin, which the plugin host's test plugin serves: its export format /// renamed (`m3u` is locallibrary's), its `echo` command taking a param of each kind the player's -/// form fills in, besides the test plugin's `log` and `context`, and its `playlist` operation -/// `tracks` too (which it leaves be). +/// form fills in, besides the test plugin's `log` and `context`, its `playlist` operation +/// `tracks` too (which it leaves be), and a setting of each kind beside its greeting. const ECHO: &str = include_str!("../../../../../crates/loclib-plugin/examples/echo/locallibrary-plugin.toml"); /// The plugins with the echo plugin among them, for this test: its manifest written in `dir` and @@ -494,8 +494,28 @@ pub fn echo_plugin(dir: &Path) { r#"params = { type = "object", properties = { name = { type = "string" } }, required = ["name"] }"#, operation, ); + // Settings of each kind the player's form fills in, besides its greeting. + let settings = r#" +[[contributes.settings]] +key = "speed" +title = "How fast" +type = "number" +default = 1.5 + +[[contributes.settings]] +key = "loud" +title = "Shout" +type = "boolean" + +[[contributes.settings]] +key = "mood" +title = "Mood" +type = "enum" +choices = ["calm", "wild"] +default = "calm" +"#; let file = dir.join("echo.toml"); - std::fs::write(&file, manifest).unwrap(); + std::fs::write(&file, manifest + settings).unwrap(); // SAFETY: nextest runs each test in a process of its own, and this runs before the test // starts a thread. unsafe { std::env::set_var("LOCALLIBRARY_TEST_PLUGINS", &file) }; diff --git a/apps/locallibrary/src/tui/form.rs b/apps/locallibrary/src/tui/form.rs index 4df9a193..9911f44b 100644 --- a/apps/locallibrary/src/tui/form.rs +++ b/apps/locallibrary/src/tui/form.rs @@ -1,6 +1,7 @@ -//! A form for a plugin's params: a field for each property of their JSON Schema, each typed as -//! the command line takes it (see [`crate::plugins::params`]), and checked against the schema -//! before anything runs. A switch and a choice of a few values are picked rather than typed. +//! A form for a plugin's params, or its settings: a field for each property of a JSON Schema, +//! each typed as the command line takes it (see [`crate::plugins::params`]), and checked against +//! the schema before anything runs or is saved. A switch and a choice of a few values are picked +//! rather than typed. use loclib_plugin::{check_params, is_path}; use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; @@ -18,8 +19,18 @@ pub struct Form { pub selected: usize, /// Why what's filled in won't do. pub error: Option, + /// The JSON Schema of what's filled in. + schema: Option, /// What it's filled in for. - pub run: Run, + pub then: For, +} + +/// What a form is filled in for. +pub enum For { + /// A plugin's command or operation's params. + Run(Run), + /// The settings of the plugin with this id. + Settings(&'static str), } pub struct Field { @@ -68,24 +79,34 @@ impl Form { /// The form for `run`'s params, each field filled in with its default (none when it takes /// none), or a list of track ids called `tracks` with the tracks selected. pub fn new(run: Run) -> Form { - let schema = run.params(); - let required: Vec<&str> = - schema.and_then(|s| s["required"].as_array()).into_iter().flatten().filter_map(Value::as_str).collect(); - let fields: Vec = properties(schema) + let schema = run.params().cloned(); + let ids = &run.context.tracks; + let tracks = properties(schema.as_ref()).find(|(name, _)| *name == "tracks").map(|(_, s)| s); + let mut filled = Map::new(); + if tracks.is_some_and(|s| takes(s) == Some(Takes::Many("integer"))) && !ids.is_empty() { + filled.insert("tracks".into(), Value::from(ids.clone())); + } + Form::with(run.title().to_string(), schema, &filled, For::Run(run)) + } + + /// The form for what `schema` describes, each field filled in as `filled` has it, else with + /// its default (a choice that can be left out with none, a switch off). + pub fn with(title: String, schema: Option, filled: &Map, then: For) -> Form { + let required: Vec<&str> = (schema.as_ref().and_then(|s| s["required"].as_array()).into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + let fields: Vec = properties(schema.as_ref()) .map(|(name, schema)| { let required = required.contains(&name.as_str()); let mut field = Field { name: name.clone(), schema: schema.clone(), required, text: String::new() }; - let ids = &run.context.tracks; - let selected = name == "tracks" && takes(schema) == Some(Takes::Many("integer")) && !ids.is_empty(); - field.text = match schema.get("default") { - Some(default) => cell(default), - None if selected => ids.iter().map(u32::to_string).collect::>().join(", "), + field.text = match filled.get(name).or(schema.get("default")) { + Some(value) => cell(value), None => field.choices().first().cloned().unwrap_or_default(), }; field }) .collect(); - Form { title: run.title().to_string(), fields, selected: 0, error: None, run } + Form { title, fields, selected: 0, error: None, schema, then } } /// The params filled in, or which field won't do and why: one that's needed is empty, one @@ -115,7 +136,7 @@ impl Form { given.insert(f.name.clone(), params::value(&f.schema, &raws, &f.label()).map_err(|e| (i, e))?); } let given = Value::Object(given); - check_params(self.run.params(), &given).map_err(|e| { + check_params(self.schema.as_ref(), &given).map_err(|e| { // The field the schema's complaint is about (`/into`), named as it's shown. let path = e.data.and_then(|d| d.fields.get("path").and_then(Value::as_str).map(String::from)); let at = path.as_deref().and_then(|p| p.trim_start_matches('/').split('/').next()); @@ -134,7 +155,8 @@ impl Form { impl App { /// Keys while a form is open: the arrows (or tab) move between fields, typing fills one in, - /// space and ←→ pick a switch's or a choice's value, enter runs it and esc closes it. + /// space and ←→ pick a switch's or a choice's value, enter runs (or saves) it and esc closes + /// it. pub(super) fn on_form_key(&mut self, mut f: Form, key: KeyEvent) { let n = f.fields.len(); let field = &mut f.fields[f.selected]; @@ -142,7 +164,12 @@ impl App { match key.code { KeyCode::Esc => return, KeyCode::Enter => match f.params() { - Ok(params) => return self.run_plugin(f.run, params), + Ok(params) => { + return match f.then { + For::Run(run) => self.run_plugin(run, params), + For::Settings(plugin) => self.save_settings(plugin, params), + }; + } Err((i, why)) => (f.selected, f.error) = (i, Some(why)), }, KeyCode::Down | KeyCode::Tab => f.selected = (f.selected + 1) % n, diff --git a/apps/locallibrary/src/tui/keymap.rs b/apps/locallibrary/src/tui/keymap.rs index 03dd0444..426da6b1 100644 --- a/apps/locallibrary/src/tui/keymap.rs +++ b/apps/locallibrary/src/tui/keymap.rs @@ -187,6 +187,8 @@ pub enum Act { Analyze(&'static str, &'static str), /// Show or hide the column of a plugin's field. FieldColumn(locallibrary::query::FieldName), + /// A plugin's settings (the plugin's id). + PluginSettings(&'static str), } /// The help's sections. diff --git a/apps/locallibrary/src/tui/palette.rs b/apps/locallibrary/src/tui/palette.rs index fa93063a..d273105c 100644 --- a/apps/locallibrary/src/tui/palette.rs +++ b/apps/locallibrary/src/tui/palette.rs @@ -182,12 +182,12 @@ pub fn commands(view: View) -> Vec<(Act, Item)> { } impl App { - /// `:`: every action by name, then plugins' commands, operations and analyzers, and in the - /// library the columns of their fields. + /// `:`: every action by name, then plugins' commands, operations and analyzers, in the + /// library the columns of their fields, and their settings. pub(super) fn open_palette(&mut self) { let acts = commands(self.view).into_iter().chain(self.plugin_items(self.action_drive().as_deref())); let fields = (self.view == View::Library).then(|| self.field_items()).into_iter().flatten(); - let (acts, items): (Vec, Vec) = acts.chain(fields).unzip(); + let (acts, items): (Vec, Vec) = acts.chain(fields).chain(self.settings_items()).unzip(); let mut p = Picker::new("Commands", items, Then::Act(acts)); p.empty = "no command has those letters"; self.modal = Some(Modal::Pick(p)); diff --git a/apps/locallibrary/src/tui/plugins.rs b/apps/locallibrary/src/tui/plugins.rs index 1e888fe2..76ad1f28 100644 --- a/apps/locallibrary/src/tui/plugins.rs +++ b/apps/locallibrary/src/tui/plugins.rs @@ -1,7 +1,8 @@ //! Plugins in the player: their commands and operations in the palette and the drive menu //! (operations in the actions menu, `e`, too), a form for their params (see [`super::form`]), and //! each run in the background as any task is (esc stops one). A command's answer shows in a panel; -//! an operation's plan is confirmed and applied as any edit is, and undone with `u`. Nothing here +//! an operation's plan is confirmed and applied as any edit is, and undone with `u`. Analyzers run +//! from the palette too, and each plugin's settings are a form, kept in the store. Nothing here //! tells one plugin from another: it's all drawn from their manifests, through the host the //! command line uses (see [`crate::plugins`]). @@ -9,18 +10,18 @@ use std::path::{Path, PathBuf}; use locallibrary::Store; use locallibrary::plugin::{Cli, CommandOutput}; -use loclib_plugin::{Capability, Context, View}; -use serde_json::Value; +use loclib_plugin::{Capability, Context, Setting, SettingType, View}; +use serde_json::{Map, Value, json}; use super::app::Fields; use super::app::{App, Level, View as Screen}; use super::drives::{Report, Tone}; use super::edits::{Modal, Target}; -use super::form::Form; +use super::form::{For, Form}; use super::keymap::Act; use super::palette::Item; use crate::plugin_cmd::view::{pairs, table}; -use crate::plugins::params::properties; +use crate::plugins::params::{cell, properties}; use locallibrary::query::{FieldName, SortField}; /// A plugin's command or operation, and where it runs: the drive, and what's selected there (its @@ -116,6 +117,27 @@ pub fn planned_by(operation: &str) -> Option<&'static str> { ours.map(|m| m.name.as_str()) } +/// Settings as a form's JSON Schema: each as text, a number, a switch, one of its choices or a +/// path, said with its title and its default. +fn settings_schema(settings: &[Setting]) -> Value { + let properties: Map = settings + .iter() + .map(|s| { + let mut p = match s.kind { + SettingType::Number => json!({ "type": "number" }), + SettingType::Boolean => json!({ "type": "boolean" }), + SettingType::Enum => json!({ "type": "string", "enum": s.choices }), + SettingType::File | SettingType::Directory => json!({ "type": "string", "x-path": true }), + SettingType::String | SettingType::Secret => json!({ "type": "string" }), + }; + let default = s.default.as_ref().map(|d| format!(" (empty for {})", cell(d))).unwrap_or_default(); + p["description"] = Value::from(format!("{}{default}", s.title)); + (s.key.clone(), p) + }) + .collect(); + json!({ "type": "object", "properties": properties }) +} + /// Whether a plugin with `capability` reads a drive: it names one when it calls back. fn reads_drive(capability: &Capability) -> bool { use Capability::*; @@ -193,6 +215,50 @@ impl App { items } + /// The settings of each plugin that has some, as the palette lists them, wherever it's open. + pub(super) fn settings_items(&self) -> Vec<(Act, Item)> { + let Ok(plugins) = crate::plugins::host() else { return Vec::new() }; + let with = plugins.manifests().filter(|m| !m.contributes.settings.is_empty()); + with.map(|m| (Act::PluginSettings(&m.id), Item::new(format!("{} plugin settings…", m.name), &m.name))).collect() + } + + /// A plugin's settings in a form, each filled in as it's set now (else its default). + pub(super) fn plugin_settings(&mut self, plugin: &'static str) { + let Ok(plugins) = crate::plugins::host() else { return }; + let Ok(m) = plugins.manifest(plugin) else { return }; + let store = Store::open(&self.store_root).ok(); + let now = plugins.settings(plugin, store.as_ref()).unwrap_or_default(); + let filled: Map = now.into_iter().collect(); + let schema = Some(settings_schema(&m.contributes.settings)); + let form = Form::with(format!("{} settings", m.name), schema, &filled, For::Settings(plugin)); + self.modal = Some(Modal::Form(form)); + } + + /// Keep a plugin's settings as the form has them (`given`), in the background: those that + /// changed, each left empty put back to its default. + pub(super) fn save_settings(&mut self, plugin: &'static str, given: Value) { + let Ok(plugins) = crate::plugins::host() else { return }; + let Ok(m) = plugins.manifest(plugin) else { return }; + let store_root = self.store_root.clone(); + self.task("Saving settings".into(), move |_| { + let store = Store::open_or_init(&store_root)?; + let now = plugins.settings(plugin, Some(&store))?; + let mut changed = 0; + for s in &m.contributes.settings { + let value = given.get(&s.key); + if value.or(s.default.as_ref()) != now.get(&s.key) { + plugins.set_setting(plugin, &store, &s.key, value.cloned())?; + changed += 1; + } + } + let said = match changed { + 0 => "nothing changed".to_string(), + _ => format!("{} settings saved", m.name), + }; + Ok(Box::new(move |app: &mut App| app.toast(said, Level::Info))) + }); + } + /// A column for each plugin's field, as the palette lists them (in the library): shown or /// hidden, with the plugin's name beside each. pub(super) fn field_items(&self) -> Vec<(Act, Item)> { @@ -291,10 +357,10 @@ impl App { return; }; let context = target.map(|t| self.context(t)).unwrap_or_default(); - let form = Form::new(Run { plugin, hook, root, context }); - match form.fields.is_empty() { - true => self.run_plugin(form.run, Value::Null), - false => self.modal = Some(Modal::Form(form)), + let run = Run { plugin, hook, root, context }; + match properties(run.params()).next() { + None => self.run_plugin(run, Value::Null), + Some(_) => self.modal = Some(Modal::Form(Form::new(run))), } } diff --git a/apps/locallibrary/src/tui/plugins/tests.rs b/apps/locallibrary/src/tui/plugins/tests.rs index 49e1a9de..6c6ccc17 100644 --- a/apps/locallibrary/src/tui/plugins/tests.rs +++ b/apps/locallibrary/src/tui/plugins/tests.rs @@ -491,3 +491,126 @@ fn plugins_columns_sort_by_their_values() { let sort = items.iter().find(|i| i.action == super::super::edits::Action::SortList).unwrap(); assert_eq!(sort.unavailable.as_deref(), Some("a plugin's column can't sort the drive's playlists")); } + +/// The settings of the plugin open in a form, from the palette. +fn settings_form(app: &mut App) -> &super::super::form::Form { + press(app, ":echo plugin settings\n"); + match &app.modal { + Some(Modal::Form(f)) => f, + _ => panic!("no form: {}", last_message(app)), + } +} + +/// A plugin's settings are in the palette, with or without a drive, and open in a form as they're +/// set now; saved, only what changed is kept, and one left empty goes back to its default. +#[test] +fn plugins_settings_are_a_form_kept_in_the_store() { + let (dir, _root) = demo_drive(); + echo_plugin(dir.path()); + let (mut app, _store) = blank(Screen::Library); + press(&mut app, ":"); + assert_eq!(entry(&app, "Echo plugin settings…").1, None); + assert_eq!(entry(&app, "Cover art plugin settings…").0.detail, "Cover art"); + assert!(picker(&app).items.iter().all(|i| i.label != "Sound analysis plugin settings…"), "it has none"); + press(&mut app, "\u{1b}"); + let (_drive, root) = demo_drive(); + let mut on = app_on(&root); + on.drives = vec![ + locallibrary::api::drive_info(&locallibrary::api::DriveRef { drive: root.display().to_string() }).unwrap(), + ]; + on.drive_state.select(Some(0)); + on.view = Screen::Drives; + press(&mut on, "e"); + assert!(picker(&on).items.iter().all(|i| !i.label.ends_with("plugin settings…")), "not a drive's"); + press(&mut app, "\u{1b}"); + let texts = |f: &super::super::form::Form| f.fields.iter().map(|x| x.text.clone()).collect::>(); + let f = settings_form(&mut app); + assert_eq!( + (f.title.as_str(), texts(f)), + ("Echo settings", vec!["hello".into(), "1.5".into(), "false".into(), "calm".into()]) + ); + let s = screen(&mut app, 120, 40); + for needle in ["Echo settings", "greeting", "Greeting (empty for hello)", "enter save · esc cancel"] { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } + // Changed: the greeting, the switch and the mood. + for _ in 0.."hello".len() { + key(&mut app, KeyCode::Backspace); + } + press(&mut app, "hi\t\t \t "); + press(&mut app, "\n"); + assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Saving settings")); + wait(&mut app, |a| a.job.is_none()); + assert_eq!(last_message(&app), "Echo settings saved"); + let plugins = crate::plugins::host().unwrap(); + let store_root = app.store_root.clone(); + let kept = || { + let store = locallibrary::Store::open(&store_root).unwrap(); + plugins.settings("echo", Some(&store)).unwrap() + }; + assert_eq!( + serde_json::to_value(kept()).unwrap(), + json!({ "greeting": "hi", "speed": 1.5, "loud": true, "mood": "wild" }) + ); + let file = store_root.join("plugins/echo/settings.json"); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(written, json!({ "greeting": "hi", "loud": true, "mood": "wild" }), "only what changed"); + // Open again: as set now. Nothing changed: nothing written. + assert_eq!(texts(settings_form(&mut app)), ["hi", "1.5", "true", "wild"]); + press(&mut app, "\n"); + wait(&mut app, |a| a.job.is_none()); + assert_eq!(last_message(&app), "nothing changed"); + // The greeting left empty: back to its default. + settings_form(&mut app); + key(&mut app, KeyCode::Backspace); + key(&mut app, KeyCode::Backspace); + press(&mut app, "\n"); + wait(&mut app, |a| a.job.is_none()); + assert_eq!(kept()["greeting"], "hello"); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert!(written.get("greeting").is_none(), "{written}"); +} + +/// A value its setting can't take is said on its field, and nothing is saved. +#[test] +fn a_setting_of_the_wrong_kind_isnt_saved() { + let (dir, _root) = demo_drive(); + echo_plugin(dir.path()); + let (mut app, _store) = blank(Screen::Library); + settings_form(&mut app); + press(&mut app, "\tfast\n"); + let Some(Modal::Form(f)) = &app.modal else { panic!("closed") }; + assert_eq!((f.selected, f.error.as_deref()), (1, Some("speed takes a number, not \"1.5fast\""))); + assert!(app.job.is_none() && !app.store_root.join("plugins").exists()); +} + +/// Each kind of setting as the form's schema has it, with its title and default said. +#[test] +fn settings_are_a_schema_of_their_kinds() { + let setting = |key: &str, kind, default: Option, choices: &[&str]| Setting { + key: key.into(), + title: key.to_uppercase(), + kind, + default, + choices: choices.iter().map(|c| c.to_string()).collect(), + }; + let schema = settings_schema(&[ + setting("a", SettingType::String, Some(json!("x")), &[]), + setting("b", SettingType::Number, None, &[]), + setting("c", SettingType::Boolean, Some(json!(true)), &[]), + setting("d", SettingType::Enum, None, &["p", "q"]), + setting("e", SettingType::File, None, &[]), + setting("f", SettingType::Directory, None, &[]), + ]); + assert_eq!( + schema["properties"], + json!({ + "a": { "type": "string", "description": "A (empty for x)" }, + "b": { "type": "number", "description": "B" }, + "c": { "type": "boolean", "description": "C (empty for true)" }, + "d": { "type": "string", "enum": ["p", "q"], "description": "D" }, + "e": { "type": "string", "x-path": true, "description": "E" }, + "f": { "type": "string", "x-path": true, "description": "F" }, + }) + ); +} diff --git a/apps/locallibrary/src/tui/screens.rs b/apps/locallibrary/src/tui/screens.rs index 41e4eb19..39ed5f6c 100644 --- a/apps/locallibrary/src/tui/screens.rs +++ b/apps/locallibrary/src/tui/screens.rs @@ -429,6 +429,24 @@ fn plugins_fields() { check(&mut app, "the search box filtering by a plugin's field", &["/ echo.bytes:>1000", "1 match", "4000"], &[]); } +/// A plugin's settings in a form, as set now, from the palette. +#[test] +fn plugins_settings() { + let Fixture { _dir, mut app, .. } = fixture(); + super::app::testing::echo_plugin(_dir.path()); + for c in ":echo plugin settings".chars() { + key(&mut app, KeyCode::Char(c)); + } + check(&mut app, "a plugin's settings in the palette", &["Echo plugin settings…", "Echo"], &[]); + key(&mut app, KeyCode::Enter); + check( + &mut app, + "a plugin's settings", + &["Echo settings", "greeting", "hello", "speed", "1.5", "loud", "○ no", "‹ calm ›", "enter save"], + &[], + ); +} + #[test] fn edit_menu_and_confirmation() { let Fixture { _dir, mut app, root } = fixture(); diff --git a/apps/locallibrary/src/tui/ui.rs b/apps/locallibrary/src/tui/ui.rs index 78029b57..0bb88250 100644 --- a/apps/locallibrary/src/tui/ui.rs +++ b/apps/locallibrary/src/tui/ui.rs @@ -2528,8 +2528,12 @@ fn draw_form(f: &mut Frame, app: &mut App, area: Rect) { } let r = popup(area, w, lines.len() as u16 + 2, None); let keys = if selected.choices().is_empty() { "" } else { "←→ change · " }; + let then = match form.then { + super::form::For::Run(_) => "run", + super::form::For::Settings(_) => "save", + }; let b = block(title(&form.title, true), true) - .title_bottom(Line::styled(format!(" ↑↓ field · {keys}enter run · esc cancel "), faint()).right_aligned()) + .title_bottom(Line::styled(format!(" ↑↓ field · {keys}enter {then} · esc cancel "), faint()).right_aligned()) .padding(ratatui::widgets::Padding::horizontal(1)); f.render_widget(Clear, r); f.render_widget(Paragraph::new(lines).block(b), r); From 9e543d3e5cc4d7da4337373c937f2c5710afdf74 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:28:55 -0400 Subject: [PATCH 5/8] plugin exports: written into folders that aren't there yet, which a plan no longer takes for gone --- apps/locallibrary/tests/it/plugins.rs | 9 +++++++++ crates/locallibrary/src/op.rs | 15 ++++++++++---- crates/locallibrary/src/op/tests.rs | 24 +++++++++++++++++++++++ crates/locallibrary/src/plugin/exports.rs | 6 +++++- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/locallibrary/tests/it/plugins.rs b/apps/locallibrary/tests/it/plugins.rs index 9ccfbe1c..7331c8e4 100644 --- a/apps/locallibrary/tests/it/plugins.rs +++ b/apps/locallibrary/tests/it/plugins.rs @@ -206,6 +206,15 @@ fn plugins_formats_ask_before_overwriting() { assert!(env.fails(1, &strs(&export(&["--split"]))).contains("are for locallibrary's own formats")); } +/// A plugin's format is written into folders that aren't there yet, made as it's written. +#[test] +fn plugins_formats_make_the_folders_they_write_into() { + let env = env(); + let out = env.outside("new/dir/list.m3u"); + env.ok(&["--yes", "export", "USB", "--format", "echo-m3u", "-o", path(&out)]); + assert!(std::fs::read_to_string(&out).unwrap().starts_with("#EXTM3U")); +} + /// `analyze` runs the analyzers on a drive's tracks once; `track` shows what they worked out. #[test] fn analyzers_fill_in_track_details() { diff --git a/crates/locallibrary/src/op.rs b/crates/locallibrary/src/op.rs index 7f713504..1b3ff592 100644 --- a/crates/locallibrary/src/op.rs +++ b/crates/locallibrary/src/op.rs @@ -161,6 +161,9 @@ pub struct Touched { pub drive: bool, #[serde(skip)] probes: Vec, + /// It was there when planned: a folder an operation makes (an export's) may not be yet. + #[serde(skip)] + existed: bool, #[serde(skip)] state: Hash, /// Its [`Probe::Databases`] alone, if probed, for the write to check again under the lock. @@ -181,6 +184,7 @@ impl Touched { drive: true, probes, databases, + existed: true, } } @@ -199,6 +203,7 @@ impl Touched { drive: false, probes, databases: None, + existed: path.exists(), } } @@ -526,12 +531,14 @@ impl Planned { } /// Whether the plan can still be applied: every drive and file it touches is as it was when - /// planned ([`Error::StalePlan`] otherwise), and none it writes is an archive or retired - /// drive unless the plan was made to write to one. + /// planned, there or not ([`Error::StalePlan`] otherwise), and none it writes is an archive or + /// retired drive unless the plan was made to write to one. pub fn check(&self, store: Option<&Store>) -> Result<()> { for t in &self.drives { - if !t.root.exists() { - return Err(Error::stale(&t.name, "is gone")); + match (t.existed, t.root.exists()) { + (true, false) => return Err(Error::stale(&t.name, "is gone")), + (false, true) => return Err(Error::stale(&t.name, "is there now")), + _ => {} } if t.changed() { return Err(Error::stale(&t.name, "changed")); diff --git a/crates/locallibrary/src/op/tests.rs b/crates/locallibrary/src/op/tests.rs index 6c0e61cf..7291dfcb 100644 --- a/crates/locallibrary/src/op/tests.rs +++ b/crates/locallibrary/src/op/tests.rs @@ -51,6 +51,30 @@ fn a_plan_is_refused_once_what_it_touches_changes() { assert!(matches!(planned.check(None), Err(Error::StalePlan(_)))); } +/// A folder on this computer that wasn't there when planned (one an export makes) is as planned +/// while it still isn't; there now, or gone once it was, the plan is stale. +#[test] +fn a_folder_missing_when_planned_is_unchanged_while_it_stays_missing() { + let dir = tempfile::tempdir().unwrap(); + let new = dir.path().join("new/dir"); + let planned = Planned::new("test", Plan { tracks: 1, warnings: Vec::new() }, ()).touches(Touched::path( + &new, + true, + vec![Probe::Stamp(vec!["list.m3u".into()])], + )); + assert!(planned.check(None).is_ok()); + std::fs::create_dir_all(&new).unwrap(); + let said = planned.check(None).unwrap_err().to_string(); + assert!(said.contains("is there now since the plan was made"), "{said}"); + let there = Planned::new("test", Plan { tracks: 1, warnings: Vec::new() }, ()).touches(Touched::path( + &new, + true, + vec![Probe::Stamp(vec!["list.m3u".into()])], + )); + std::fs::remove_dir(&new).unwrap(); + assert!(there.check(None).unwrap_err().to_string().contains("is gone since the plan was made")); +} + /// Writes its drive's database before it first locks the drive (`true`: as another write /// slipping in after the check would), and between two locks (as its own next write does). struct Writes; diff --git a/crates/locallibrary/src/plugin/exports.rs b/crates/locallibrary/src/plugin/exports.rs index f7d5ab7a..f0f23904 100644 --- a/crates/locallibrary/src/plugin/exports.rs +++ b/crates/locallibrary/src/plugin/exports.rs @@ -13,6 +13,7 @@ use serde_json::Value; use super::Plugins; use super::process::Slot; use crate::api; +use crate::error::IoContext; use crate::op::{Operation, Planned, Probe, Touched}; use crate::progress::Ctx; use crate::store::Store; @@ -165,10 +166,13 @@ impl Operation for PluginExport { type Request = (); type Report = Exported; - /// Has the plugin write the files it said it would, refusing (once written) any other. + /// Has the plugin write the files it said it would, refusing (once written) any other. The + /// folder the output is in is made first, as locallibrary's own exports make it. fn apply(planned: &Planned, store: Option<&Store>, ctx: &Ctx<'_>) -> Result { let e = &planned.plan; outside_drives(&e.output, store)?; + let dir = e.output.parent().unwrap_or(&e.output); + std::fs::create_dir_all(dir).at(dir)?; let slot = e.slot.upgrade().ok_or_else(|| Error::stale(format!("the {} plugin", e.plugin), "stopped"))?; let written: Exported = slot.call(method::WRITE_EXPORT, &e.write, ctx)?; // By path: a file's size is known exactly only once it's written. From c6d297faa805a70ddc22b22d9e41e4201cc0525c Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:10:44 -0400 Subject: [PATCH 6/8] tui: plugins' export formats in the palette and a drive's menu, with a form for their options, what they'd write shown first and overwriting asked about --- apps/locallibrary/CHANGELOG.md | 11 +- apps/locallibrary/docs/other-software.md | 6 +- apps/locallibrary/docs/player.md | 5 +- apps/locallibrary/docs/plugins.md | 9 +- apps/locallibrary/src/export_cmd.rs | 23 ++- apps/locallibrary/src/tui/app.rs | 1 + apps/locallibrary/src/tui/app/testing.rs | 8 + apps/locallibrary/src/tui/drives.rs | 1 + apps/locallibrary/src/tui/edits.rs | 26 ++++ apps/locallibrary/src/tui/form.rs | 8 +- apps/locallibrary/src/tui/keymap.rs | 2 + apps/locallibrary/src/tui/plugins.rs | 112 ++++++++++++-- apps/locallibrary/src/tui/plugins/tests.rs | 139 +++++++++++++++++- apps/locallibrary/src/tui/screens.rs | 48 ++++++ apps/locallibrary/src/tui/ui.rs | 39 ++++- .../locallibrary/examples/test-plugin/main.rs | 12 +- .../examples/test-plugin/plugin.rs | 4 + 17 files changed, 403 insertions(+), 51 deletions(-) diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index c138ac47..8cda5974 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -215,12 +215,13 @@ Windows builds are experimental and untested on hardware (see Platforms in the R and `track.fields` reads the installed plugins' versions. A plugin whose id or commands clash with locallibrary's or another plugin's is left out, with a warning saying why, and the others work; a plugin's errors keep their kinds (`not_found`, `io`...). In the player, plugins' - commands and operations are in the palette and a drive's menu (operations in the actions menu - too), asking for their params in a form drawn from their schemas; a command answers in a panel, - an operation's plan is confirmed, written and undone like any edit, analyzers run from the + commands, operations and export formats are in the palette and a drive's menu (operations in + the actions menu too), asking for their params or options in a form drawn from their schemas; + a command answers in a panel, an operation's plan is confirmed, written and undone like any + edit, an export shows what it would write and asks before overwriting, analyzers run from the palette and their fields are columns to sort and search by, plugins' settings are a form in the - palette, and what plugins log goes to its messages (`!`). `tracks --filter`, `tracks --sort` and the sidecar's `tracks.query` take - analyzers' fields (`sound.bpm:>120`, `-sound.bpm`). See + palette, and what plugins log goes to its messages (`!`). `tracks --filter`, `tracks --sort` + and the sidecar's `tracks.query` take analyzers' fields (`sound.bpm:>120`, `-sound.bpm`). See docs/plugins.md. The ones it ships: - `tracklist`: `playlist import` makes a playlist from a tracklist (M3U, CSV exports of streaming playlists, rekordbox text or XML, "Artist - Title" lines), and `tracklist match` diff --git a/apps/locallibrary/docs/other-software.md b/apps/locallibrary/docs/other-software.md index b8466852..afd4abea 100644 --- a/apps/locallibrary/docs/other-software.md +++ b/apps/locallibrary/docs/other-software.md @@ -86,8 +86,10 @@ files the folder already holds is asked about first unless `--replace` (the fold program's own), and they're replaced only once every new file is written beside them; `--option skip_analysis=true` leaves beat grids and cues out (Traktor, and Serato's tags), and `--dry-run` lists each file with its size, what goes in and what the format leaves out, without -writing. RPC: `plugins.export.plan` and `plugins.export.apply` with `plugin: "interop"`, the -format as `exporter` and the options as `options` (see [Building a GUI](sidecar.md)). +writing. In the player, "export for traktor…" (serato, engine) in the palette does the same, +for the marked playlists or the whole drive (see [the player](player.md)). RPC: +`plugins.export.plan` and `plugins.export.apply` with `plugin: "interop"`, the format as +`exporter` and the options as `options` (see [Building a GUI](sidecar.md)). - **Traktor** (`collection.nml`): tracks with their details, tempo and key, the beat grid as one grid marker on the first downbeat (locked, so Traktor doesn't analyse it again), hot cues A-H diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index c388db77..adabe5ae 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -144,7 +144,10 @@ listening to your library the way the players see it: signs in (type your handle, approve in the browser), and signed in, `L` twice signs out. - **Export**: in the palette, "export" writes the playlist, set, session or all tracks shown as M3U8, CSV or text, and "xml" writes rekordbox XML of the drive (just the marked playlists when - some are), asking where first. + some are), asking where first. A plugin's format ("export for traktor…", in a drive's `e` menu + too) asks where and for the format's options in a form, then shows what it would write (each + file, what goes in, what the format can't hold) and writes it on `y`, asking first when that + overwrites files there already; it takes the marked playlists too. - **Mixes** (`5`): a recorded set and the tracks it plays, like [`locallibrary mix`](sets.md#mixes) against the drive open in the library. Pick one of the drive's recordings (XDJ-AZ and OPUS-QUAD MASTER REC files), or `O` to type the path of a recording or a folder of them on this computer. A diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index 096788ad..810da148 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -50,10 +50,11 @@ with each other are both left out. plugin's options as `--option KEY=VALUE`. `--dry-run` lists what it would write (each file, what goes in, what the format can't hold), and overwriting a file is asked about first unless `--replace`. -- **In the player** (`locallibrary tui`), commands, operations and analyzers are in the palette - (`:`) and a drive's menu, operations in the actions menu (`e`) too, with a form for their - params; analyzers' fields are columns to show, sort and search by, and each plugin's settings - are a form in the palette too (see [the player](player.md)). +- **In the player** (`locallibrary tui`), commands, operations, analyzers and export formats are + in the palette (`:`) and a drive's menu, operations in the actions menu (`e`) too, with a form + for their params or options; an export shows what it would write and asks before writing it; + analyzers' fields are columns to show, sort and search by, and each plugin's settings are a + form in the palette too (see [the player](player.md)). - **Settings**: `locallibrary plugin settings ID` shows them, `plugin settings ID KEY VALUE` sets one and `plugin settings ID KEY --reset` puts it back to its default. They're kept in the store (`plugins/ID/settings.json`). diff --git a/apps/locallibrary/src/export_cmd.rs b/apps/locallibrary/src/export_cmd.rs index 683285e4..775a7cdc 100644 --- a/apps/locallibrary/src/export_cmd.rs +++ b/apps/locallibrary/src/export_cmd.rs @@ -11,7 +11,7 @@ use clap::{Args, ValueEnum}; use locallibrary::api; use locallibrary::export::{Format, PathStyle}; use locallibrary::op::Planned; -use locallibrary::plugin::PluginExport; +use locallibrary::plugin::{ExportFile, PluginExport}; use locallibrary::progress::Ctx; use loclib_plugin::ExportSource; @@ -191,20 +191,27 @@ fn run_plugin( })) } +/// How many of a plugin's export's files are listed before "and N more" (here and in the player). +pub(crate) const FILES_SHOWN: usize = 20; + +/// What one of a plugin's export's files holds and its size, as far as the plugin says. +pub(crate) fn about(f: &ExportFile) -> String { + let about = + [(!f.file.what.is_empty()).then(|| f.file.what.clone()), (f.file.bytes > 0).then(|| bytes(f.file.bytes))]; + about.into_iter().flatten().collect::>().join(", ") +} + /// A plugin's export as locallibrary's own were shown: what it writes and what goes in, its first /// files (what each holds, and those there already), what the format can't hold, then problems /// with the library. fn render_export(p: &Planned) { crate::output::summary(&p.summary, &[]); - for f in p.files.iter().take(20) { - let about = - [(!f.file.what.is_empty()).then(|| f.file.what.clone()), (f.file.bytes > 0).then(|| bytes(f.file.bytes))]; - let about = about.into_iter().flatten().collect::>().join(", "); + for f in p.files.iter().take(FILES_SHOWN) { let replaces = if f.exists { " (already there)" } else { "" }; - say!(" {} {}{replaces}", f.file.path.display(), dim(about)); + say!(" {} {}{replaces}", f.file.path.display(), dim(about(f))); } - if p.files.len() > 20 { - say!(" {}", dim(format!("... and {} more", p.files.len() - 20))); + if p.files.len() > FILES_SHOWN { + say!(" {}", dim(format!("... and {} more", p.files.len() - FILES_SHOWN))); } for n in &p.notes { say!(" {}", dim(n)); diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs index a700ab95..c4e4e532 100644 --- a/apps/locallibrary/src/tui/app.rs +++ b/apps/locallibrary/src/tui/app.rs @@ -3311,6 +3311,7 @@ impl App { Act::Analyze(plugin, analyzer) => self.analyze(plugin, analyzer, self.action_drive()), Act::FieldColumn(name) => self.toggle_field_column(name), Act::PluginSettings(plugin) => self.plugin_settings(plugin), + Act::Export(plugin, format) => self.plugin_export(plugin, format, self.action_drive()), Act::Details => { self.show_details = !self.show_details; if !self.show_details && self.focus == Focus::Details { diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index 5cf4300e..31a0f6d2 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -520,3 +520,11 @@ default = "calm" // starts a thread. unsafe { std::env::set_var("LOCALLIBRARY_TEST_PLUGINS", &file) }; } + +/// The drives in `dir` taken for mounted ones, for this test: a plugin finds the drive it's +/// given by its name among those (the echo plugin's exporter reads the drive). Call it before the +/// app starts. +pub fn mounted(dir: &Path) { + // SAFETY: as in `echo_plugin`. + unsafe { std::env::set_var("LOCALLIBRARY_VOLUMES", dir) }; +} diff --git a/apps/locallibrary/src/tui/drives.rs b/apps/locallibrary/src/tui/drives.rs index 35eb7f07..fa11cf47 100644 --- a/apps/locallibrary/src/tui/drives.rs +++ b/apps/locallibrary/src/tui/drives.rs @@ -324,6 +324,7 @@ impl App { Act::Eject => self.eject(vec![root]), Act::Plugin(plugin, name) => self.plugin_act(plugin, name, Some(root), None), Act::Analyze(plugin, analyzer) => self.analyze(plugin, analyzer, Some(root)), + Act::Export(plugin, format) => self.plugin_export(plugin, format, Some(root)), act => self.perform(act, None), } } diff --git a/apps/locallibrary/src/tui/edits.rs b/apps/locallibrary/src/tui/edits.rs index 0fd66a2b..20ef8424 100644 --- a/apps/locallibrary/src/tui/edits.rs +++ b/apps/locallibrary/src/tui/edits.rs @@ -16,6 +16,7 @@ use locallibrary::api::{self, EditRequest}; use locallibrary::edit::{Edit, EditOptions, EditPlan, TrackFields}; use locallibrary::model::TrackColor; use locallibrary::op::{AnyPlanned, Planned}; +use locallibrary::plugin::PluginExport; use locallibrary::progress::{Event, Phase, Progress}; use locallibrary::query::{SortField, SortKey, TrackSelector}; use locallibrary::{CancelToken, Ctx, Error}; @@ -26,6 +27,7 @@ use super::app::{App, Focus, Level, Msg, View, edit_line}; use super::browse::{COLOURS, NodeId, colour_name, sort_label, sort_spec}; use super::drives::{After, Report}; use super::keymap::{self, Act}; +use crate::output::plural; // --------------------------------------------------------------------------------------------- // Edits @@ -352,6 +354,11 @@ impl Confirm { self.plan.as_any().downcast_ref::>().map(|p| &p.plan) } + /// The plan, when it's a plugin's export (shown in detail). + pub fn export(&self) -> Option<&Planned> { + self.plan.as_any().downcast_ref::>() + } + /// The plan, when it's undoing (shown in detail). pub fn undo(&self) -> Option<&api::UndoPlan> { self.plan.as_any().downcast_ref::>().map(|p| &p.plan) @@ -485,6 +492,15 @@ pub struct Wording { } pub fn wording(plan: &dyn AnyPlanned, drive: &str) -> Wording { + // A plugin's export writes files on this computer: overwriting any is the question. + if let Some(e) = plan.as_any().downcast_ref::>() { + let (ask, yes) = match e.plan.files.iter().filter(|f| f.exists).count() { + 0 => (format!("Write {}?", plural(e.plan.files.len(), "file")), "write"), + n => (format!("Overwrite {}?", plural(n, "file")), "overwrite"), + }; + let who = e.plan.output.display().to_string(); + return Wording { ask, yes, who, what: "export".into(), doing: "Exporting".into() }; + } let written: Vec<&str> = (plan.drives().iter()).filter(|t| t.writes && t.drive).map(|t| t.name.as_str()).collect(); let who = if written.is_empty() { drive.to_string() } else { written.join(", ") }; let w = @@ -1142,6 +1158,7 @@ impl App { let msg = match (plan.operation(), plan.warnings().first()) { ("undo", _) => format!("nothing to undo on {drive}: it's as its last backup left it"), ("pull", _) => plan.summary().first().cloned().unwrap_or_else(|| "saved already".into()), + _ if plan.as_any().is::>() => "nothing to write".into(), (_, Some(w)) => format!("nothing to change: {w}"), (_, None) => "nothing to change".into(), }; @@ -1288,6 +1305,15 @@ impl App { self.toast(msg, Level::Info); self.refresh_status(); } + Ok(Applied { operation, report }) if super::plugins::planned_by(&operation, true).is_some() => { + let by = super::plugins::planned_by(&operation, true).unwrap_or_default(); + let n = report["files"].as_array().map_or(0, Vec::len); + let mut msg = format!("{by}: {} written", plural(n, "file")); + if let Some(w) = report["warnings"].as_array().and_then(|w| w.first()).and_then(|w| w.as_str()) { + msg.push_str(&format!(" · {w}")); + } + self.toast(msg, Level::Info); + } Ok(Applied { operation, report }) if operation == "undo" => { let when = report["created"].as_str().and_then(|t| t.get(..16)).unwrap_or("").replace('T', " "); let why = report["reason"].as_str().unwrap_or("a change"); diff --git a/apps/locallibrary/src/tui/form.rs b/apps/locallibrary/src/tui/form.rs index 9911f44b..14bd9993 100644 --- a/apps/locallibrary/src/tui/form.rs +++ b/apps/locallibrary/src/tui/form.rs @@ -1,4 +1,4 @@ -//! A form for a plugin's params, or its settings: a field for each property of a JSON Schema, +//! A form for a plugin's params, its settings, or an export's: a field for each property of a JSON Schema, //! each typed as the command line takes it (see [`crate::plugins::params`]), and checked against //! the schema before anything runs or is saved. A switch and a choice of a few values are picked //! rather than typed. @@ -31,6 +31,9 @@ pub enum For { Run(Run), /// The settings of the plugin with this id. Settings(&'static str), + /// Where to export the drive at `root` (its `playlists` only, when some are named) in a + /// plugin's format, and the format's options. + Export { plugin: &'static str, format: &'static str, root: std::path::PathBuf, playlists: Vec }, } pub struct Field { @@ -168,6 +171,9 @@ impl App { return match f.then { For::Run(run) => self.run_plugin(run, params), For::Settings(plugin) => self.save_settings(plugin, params), + For::Export { plugin, format, root, playlists } => { + self.plan_export(plugin, format, root, playlists, params) + } }; } Err((i, why)) => (f.selected, f.error) = (i, Some(why)), diff --git a/apps/locallibrary/src/tui/keymap.rs b/apps/locallibrary/src/tui/keymap.rs index 426da6b1..f65d6698 100644 --- a/apps/locallibrary/src/tui/keymap.rs +++ b/apps/locallibrary/src/tui/keymap.rs @@ -189,6 +189,8 @@ pub enum Act { FieldColumn(locallibrary::query::FieldName), /// A plugin's settings (the plugin's id). PluginSettings(&'static str), + /// Export the drive in a plugin's format (the plugin's id, the format). + Export(&'static str, &'static str), } /// The help's sections. diff --git a/apps/locallibrary/src/tui/plugins.rs b/apps/locallibrary/src/tui/plugins.rs index 76ad1f28..0fbfee81 100644 --- a/apps/locallibrary/src/tui/plugins.rs +++ b/apps/locallibrary/src/tui/plugins.rs @@ -2,7 +2,8 @@ //! (operations in the actions menu, `e`, too), a form for their params (see [`super::form`]), and //! each run in the background as any task is (esc stops one). A command's answer shows in a panel; //! an operation's plan is confirmed and applied as any edit is, and undone with `u`. Analyzers run -//! from the palette too, and each plugin's settings are a form, kept in the store. Nothing here +//! from the palette too, exports are planned and confirmed (overwriting asked about) before the +//! plugin writes them, and each plugin's settings are a form, kept in the store. Nothing here //! tells one plugin from another: it's all drawn from their manifests, through the host the //! command line uses (see [`crate::plugins`]). @@ -10,7 +11,7 @@ use std::path::{Path, PathBuf}; use locallibrary::Store; use locallibrary::plugin::{Cli, CommandOutput}; -use loclib_plugin::{Capability, Context, Setting, SettingType, View}; +use loclib_plugin::{Capability, Context, ExportSource, Setting, SettingType, View}; use serde_json::{Map, Value, json}; use super::app::Fields; @@ -106,17 +107,26 @@ fn named(title: &str) -> String { } } -/// The name of the plugin whose operation `operation` (`.`, as its plan is called) -/// is, if it's a plugin's. -pub fn planned_by(operation: &str) -> Option<&'static str> { +/// The name of the plugin whose operation (`.`) or, with `export`, export +/// (`.export.`) `operation` is, as its plan is called, if it's a plugin's. +pub fn planned_by(operation: &str, export: bool) -> Option<&'static str> { let mut all = crate::plugins::host().ok()?.manifests(); let ours = all.find(|m| { - let name = operation.strip_prefix(m.id.as_str()).and_then(|rest| rest.strip_prefix('.')); - name.is_some_and(|name| m.contributes.operations.iter().any(|o| o.name == name)) + let c = &m.contributes; + match (operation.strip_prefix(m.id.as_str()).and_then(|rest| rest.strip_prefix('.')), export) { + (Some(rest), true) => { + rest.strip_prefix("export.").is_some_and(|f| c.exporters.iter().any(|e| e.format == f)) + } + (Some(name), false) => c.operations.iter().any(|o| o.name == name), + (None, _) => false, + } }); ours.map(|m| m.name.as_str()) } +/// The form's own field an export's output is typed in, beside the format's options. +const WRITE_TO: &str = "write_to"; + /// Settings as a form's JSON Schema: each as text, a number, a switch, one of its choices or a /// path, said with its title and its default. fn settings_schema(settings: &[Setting]) -> Value { @@ -190,8 +200,8 @@ impl App { root.map(|root| !self.extra_roots.iter().any(|r| r == root)) } - /// Every plugin's commands, operations and analyzers, as the palette and the drive menu list - /// them for the drive at `root`: by title, with the plugin's name beside each. + /// Every plugin's commands, operations, analyzers and export formats, as the palette and the + /// drive menu list them for the drive at `root`: by title, with the plugin's name beside each. pub(super) fn plugin_items(&self, root: Option<&Path>) -> Vec<(Act, Item)> { let drive = self.pluggable(root); let all = offered(drive, true).into_iter(); @@ -199,7 +209,8 @@ impl App { .map(|o| (Act::Plugin(o.plugin, o.name), Item { unavailable: o.unavailable, ..Item::new(o.label, o.by) })) .collect(); let Ok(plugins) = crate::plugins::host() else { return items }; - // An analyzer reads the tracks' audio files: on a drive plugged in, as a command. + // An analyzer reads the tracks' audio files and an exporter the library: on a drive plugged + // in, as a command. let why = match drive { None => Some("open a drive first"), Some(false) => Some("only on a plugged-in drive"), @@ -211,10 +222,85 @@ impl App { let item = Item { unavailable: why.map(String::from), ..Item::new(label, &m.name) }; items.push((Act::Analyze(&m.id, &a.name), item)); } + for e in &m.contributes.exporters { + let item = Item { + unavailable: why.map(String::from), + ..Item::new(format!("export for {}…", e.format), &m.name) + }; + items.push((Act::Export(&m.id, &e.format), item)); + } } items } + /// The drive at `root`, when plugins can run on it; else `None`, saying why. + fn plugged(&mut self, root: Option) -> Option { + match (self.pluggable(root.as_deref()), root) { + (Some(true), Some(root)) => Some(root), + (None, _) => { + self.toast("open a drive first", Level::Info); + None + } + _ => { + self.toast("only on a plugged-in drive", Level::Info); + None + } + } + } + + /// Export the drive at `root` in a plugin's format (the playlists marked in the tree only, when + /// it's the drive open and some are, as rekordbox XML is): a form asks where (a file or folder + /// on this computer, named after the drive) and for the format's options first. + pub(super) fn plugin_export(&mut self, plugin: &'static str, format: &'static str, root: Option) { + let Ok(plugins) = crate::plugins::host() else { return }; + let Some(root) = self.plugged(root) else { return }; + let exporter = + plugins.manifest(plugin).ok().and_then(|m| m.contributes.exporters.iter().find(|e| e.format == format)); + let Some(e) = exporter else { return }; + let name = self.drive_label(&root); + let (to, what) = match &e.extension { + Some(ext) => (format!("~/Music/{name}.{ext}"), "The file to write, on this computer"), + None => (format!("~/Music/{name} for {format}"), "The folder to write into, on this computer"), + }; + let mut fields = Map::new(); + fields.insert(WRITE_TO.into(), json!({ "type": "string", "x-path": true, "description": what })); + fields.extend(properties(e.options.as_ref()).map(|(k, v)| (k.clone(), v.clone()))); + let mut schema = e.options.clone().unwrap_or_else(|| json!({ "type": "object" })); + schema["properties"] = Value::Object(fields); + let mut required = vec![Value::from(WRITE_TO)]; + required.extend(schema["required"].as_array().cloned().unwrap_or_default()); + schema["required"] = Value::Array(required); + let filled = Map::from_iter([(WRITE_TO.to_string(), Value::from(to))]); + let open = self.loaded.as_ref().is_some_and(|l| l.root() == root); + let playlists = if open { self.list_marks.clone() } else { Vec::new() }; + let then = For::Export { plugin, format, root, playlists }; + let form = Form::with(e.title.clone(), Some(schema), &filled, then); + self.modal = Some(Modal::Form(form)); + } + + /// Plan the export the form has (`given`: where, and the format's options) in the background: + /// the plugin says which files it would write, writing none, and the plan comes back to + /// confirm, as any change does (overwriting files there already asked about). + pub(super) fn plan_export( + &mut self, + plugin: &'static str, + format: &'static str, + root: PathBuf, + playlists: Vec, + given: Value, + ) { + let Ok(plugins) = crate::plugins::host() else { return }; + let mut options = given; + let output = options.as_object_mut().and_then(|o| o.remove(WRITE_TO)); + let output = PathBuf::from(output.as_ref().and_then(Value::as_str).unwrap_or_default()); + let store_root = self.store_root.clone(); + let source = ExportSource { drive: root.display().to_string(), playlists, history: Vec::new() }; + self.plan(root, move |ctx| { + let store = Store::open(&store_root).ok(); + Ok(Box::new(plugins.export_plan(plugin, format, source, &output, options, store.as_ref(), ctx)?)) + }); + } + /// The settings of each plugin that has some, as the palette lists them, wherever it's open. pub(super) fn settings_items(&self) -> Vec<(Act, Item)> { let Ok(plugins) = crate::plugins::host() else { return Vec::new() }; @@ -289,11 +375,7 @@ impl App { /// it), keeping what it works out in the store; then its columns, filters and sorts read it. pub(super) fn analyze(&mut self, plugin: &'static str, analyzer: &'static str, root: Option) { let Ok(plugins) = crate::plugins::host() else { return }; - let root = match (self.pluggable(root.as_deref()), root) { - (Some(true), Some(root)) => root, - (None, _) => return self.toast("open a drive first", Level::Info), - _ => return self.toast("only on a plugged-in drive", Level::Info), - }; + let Some(root) = self.plugged(root) else { return }; let Some(declared) = plugins.manifest(plugin).ok().and_then(|m| m.contributes.analyzers.iter().find(|a| a.name == analyzer)) else { diff --git a/apps/locallibrary/src/tui/plugins/tests.rs b/apps/locallibrary/src/tui/plugins/tests.rs index 6c6ccc17..ae9da5e7 100644 --- a/apps/locallibrary/src/tui/plugins/tests.rs +++ b/apps/locallibrary/src/tui/plugins/tests.rs @@ -3,7 +3,7 @@ use ratatui::crossterm::event::KeyCode; use serde_json::json; use super::super::app::testing::{ - app_on, blank, confirming, demo_drive, echo_plugin, key, last_message, press, say_yes, wait, + app_on, blank, confirming, demo_drive, echo_plugin, key, last_message, mounted, press, say_yes, wait, }; use super::super::palette::Picker; use super::super::ui::screen; @@ -56,7 +56,8 @@ fn plugins_commands_are_in_the_palette() { assert_eq!(entry(&app, "answer the params…").1, Some("only on a plugged-in drive")); } -/// The drive menu ends with plugins' commands, operations and analyzers, which run on that drive. +/// The drive menu ends with plugins' commands, operations, analyzers and export formats, which +/// run on that drive. #[test] fn plugins_commands_are_in_the_drive_menu() { let (dir, root) = demo_drive(); @@ -69,7 +70,8 @@ fn plugins_commands_are_in_the_drive_menu() { press(&mut app, "e"); let p = picker(&app); assert!(p.title.starts_with("Drive · "), "{}", p.title); - assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("analyze the tracks: file size")); + assert_eq!(p.items.last().map(|i| i.label.as_str()), Some("export for echo-m3u…")); + assert!(p.items.iter().any(|i| i.label == "analyze the tracks: file size")); assert!(p.items.iter().any(|i| i.label == "make a playlist…")); assert_eq!(entry(&app, "answer the params…").1, None); press(&mut app, "wait until\n"); @@ -326,15 +328,22 @@ fn planning_an_operation_is_stopped_with_esc() { assert!(level == Level::Error && said.contains("/nowhere/list.m3u"), "{said}"); } -/// A plan is a plugin's when its operation is one of a plugin's, by the plugin's id and its name. +/// A plan is a plugin's when its operation is one of a plugin's operations or exports, by the +/// plugin's id and its name (or `export.` and the format). #[test] fn plans_are_known_by_their_plugins() { let (dir, _root) = demo_drive(); echo_plugin(dir.path()); - assert_eq!(planned_by("echo.playlist"), Some("Echo")); - assert_eq!(planned_by("tracklist.import"), Some("Tracklist import")); + assert_eq!(planned_by("echo.playlist", false), Some("Echo")); + assert_eq!(planned_by("tracklist.import", false), Some("Tracklist import")); + assert_eq!(planned_by("echo.export.echo-m3u", true), Some("Echo")); + assert_eq!(planned_by("interop.export.traktor", true), Some("Other DJ software")); + assert_eq!(planned_by("echo.export.echo-m3u", false), None, "not an operation"); + assert_eq!(planned_by("echo.playlist", true), None, "not an export"); + assert_eq!(planned_by("echo.export.nope", true), None); + assert_eq!(planned_by("echo.echo-m3u", true), None, "an export is `export.` and its format"); for not in ["echo.echo", "echo.nope", "echo", "edit", "edits.replay", "echoplaylist"] { - assert_eq!(planned_by(not), None, "{not}"); + assert_eq!(planned_by(not, false), None, "{not}"); } } @@ -614,3 +623,119 @@ fn settings_are_a_schema_of_their_kinds() { }) ); } + +/// The export form open, its "write to" replaced with `to`. +fn export_to(app: &mut App, to: &std::path::Path) { + press(app, ":export for echo\n"); + let Some(Modal::Form(f)) = &mut app.modal else { panic!("no form: {}", last_message(app)) }; + f.fields[0].text = to.display().to_string(); + press(app, "\n"); +} + +/// Exporting from the palette: on a drive plugged in, a form asks where (named after the drive) +/// and for the format's options; the plugin says what it would write, writing nothing, shown as +/// the command line's dry run is, and a yes writes it. Files there already are asked about as +/// overwriting, and a no leaves them be. +#[test] +fn an_export_is_planned_confirmed_and_written() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let (mut app, _store) = blank(Screen::Library); + press(&mut app, ":"); + assert_eq!(entry(&app, "export for echo-m3u…").1, Some("open a drive first")); + assert_eq!(entry(&app, "export for traktor…").0.detail, "Other DJ software"); + press(&mut app, "\u{1b}"); + app.plugin_export("echo", "echo-m3u", None); + assert_eq!((app.modal.is_none(), last_message(&app)), (true, "open a drive first")); + + mounted(dir.path()); + let mut app = app_on(&root); + press(&mut app, ":export for echo\n"); + let Some(Modal::Form(f)) = &app.modal else { panic!("no form: {}", last_message(&app)) }; + let name = app.drive_label(&root); + assert_eq!((f.title.as_str(), f.fields[0].text.clone()), ("M3U playlist", format!("~/Music/{name}.m3u"))); + press(&mut app, "\u{1b}"); + let out = dir.path().join("list.m3u"); + export_to(&mut app, &out); + assert_eq!(app.job.as_ref().map(|j| j.label.as_str()), Some("Planning")); + let (operation, summary) = confirming(&mut app); + assert_eq!(operation, "echo.export.echo-m3u"); + assert!( + summary.starts_with("write 1 file(s) (") && summary.ends_with(&format!("to {}", out.display())), + "{summary}" + ); + assert!(!out.exists(), "a dry run writes nothing"); + let s = screen(&mut app, 120, 40); + for needle in ["Write 1 file?", "2 tracks", "list.m3u playlist,", "y write"] { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } + say_yes(&mut app); + assert_eq!(last_message(&app), "Echo: 1 file written"); + assert!(std::fs::read_to_string(&out).unwrap().starts_with("#EXTM3U")); + + // There already: asked about as overwriting, and left be on a no. + std::fs::write(&out, "mine").unwrap(); + export_to(&mut app, &out); + confirming(&mut app); + let s = screen(&mut app, 120, 40); + for needle in ["Overwrite 1 file?", "(already there)", "This replaces files already there.", "y overwrite"] { + assert!(s.contains(needle), "{needle:?} isn't in\n{s}"); + } + super::super::app::testing::settle(&mut app); + press(&mut app, "n"); + assert_eq!((last_message(&app), std::fs::read_to_string(&out).unwrap().as_str()), ("nothing written", "mine")); +} + +/// An export is never written onto a drive: planning it says so, as an error, and writes nothing. +#[test] +fn an_export_onto_the_drive_is_refused() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + mounted(dir.path()); + let mut app = app_on(&root); + export_to(&mut app, &root.join("list.m3u")); + wait(&mut app, |a| a.job.is_none()); + let (said, level) = app.toast.clone().map(|t| (t.0, t.1)).unwrap(); + assert!(level == Level::Error && said.contains("choose a folder on this computer"), "{said}"); + assert!(app.modal.is_none() && !root.join("list.m3u").exists()); +} + +/// A format's options are fields of the export's form, after where to write it, which a folder +/// format names after the drive and the format. +#[test] +fn an_exports_form_has_the_formats_options() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + let mut app = app_on(&root); + press(&mut app, ":export for traktor\n"); + let Some(Modal::Form(f)) = &app.modal else { panic!("no form: {}", last_message(&app)) }; + let names: Vec<&str> = f.fields.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names, ["write_to", "location_root", "skip_analysis"]); + let name = app.drive_label(&root); + assert_eq!(f.fields[0].text, format!("~/Music/{name} for traktor")); + assert!(f.fields[0].required && f.fields[2].is_switch()); +} + +/// Playlists marked in the tree are what's exported, as with rekordbox XML: the plugin gets +/// exactly those. +#[test] +fn an_export_takes_the_marked_playlists() { + let (dir, root) = demo_drive(); + echo_plugin(dir.path()); + mounted(dir.path()); + let mut app = app_on(&root); + // (The test plugin takes any names: it only says which it got.) + let names = ["Sets/Friday".to_string(), "Warmup".to_string()]; + app.list_marks = names.to_vec(); + export_to(&mut app, &dir.path().join("marked.m3u")); + confirming(&mut app); + let Some(Modal::Confirm(c)) = &app.modal else { panic!() }; + assert_eq!(c.export().unwrap().plan.notes, [format!("playlists: {}", names.join(", "))]); + assert!(screen(&mut app, 120, 40).contains(&format!("playlists: {}", names[0]))); + press(&mut app, "\u{1b}"); + app.list_marks.clear(); + export_to(&mut app, &dir.path().join("all.m3u")); + confirming(&mut app); + let Some(Modal::Confirm(c)) = &app.modal else { panic!() }; + assert!(c.export().unwrap().plan.notes.is_empty(), "none marked: the whole drive"); +} diff --git a/apps/locallibrary/src/tui/screens.rs b/apps/locallibrary/src/tui/screens.rs index 39ed5f6c..8b5501cd 100644 --- a/apps/locallibrary/src/tui/screens.rs +++ b/apps/locallibrary/src/tui/screens.rs @@ -447,6 +447,54 @@ fn plugins_settings() { ); } +/// An export in a plugin's format: its form, with the format's options, and its plan to confirm, +/// a file there already to overwrite. +#[test] +fn plugins_exports() { + let Fixture { _dir, mut app, root } = fixture(); + super::app::testing::echo_plugin(_dir.path()); + super::app::testing::mounted(root.parent().unwrap()); + for c in ":export for traktor".chars() { + key(&mut app, KeyCode::Char(c)); + } + key(&mut app, KeyCode::Enter); + check( + &mut app, + "an export's form", + &["Traktor Pro: collection.nml", "write to", "~/Music/", "location root", "skip analysis", "○ no", "enter run"], + &[], + ); + key(&mut app, KeyCode::Esc); + let out = _dir.path().join("Friday.m3u"); + std::fs::write(&out, "mine").unwrap(); + let source = + loclib_plugin::ExportSource { drive: root.display().to_string(), playlists: Vec::new(), history: Vec::new() }; + let plugins = crate::plugins::host().unwrap(); + let plan = + plugins.export_plan("echo", "echo-m3u", source, &out, serde_json::Value::Null, None, &Ctx::silent()).unwrap(); + app.modal = Some(Modal::Confirm(Confirm { + root: root.clone(), + drive: app.drive_label(&root), + plan: Box::new(plan), + scroll: 0, + shown: Instant::now() - Duration::from_secs(1), + after: None, + })); + check( + &mut app, + "an export's plan to confirm", + &[ + "Overwrite 1 file?", + "write 1 file(s)", + "2 tracks", + "Friday.m3u playlist,", + "(already there)", + "y overwrite", + ], + &[], + ); +} + #[test] fn edit_menu_and_confirmation() { let Fixture { _dir, mut app, root } = fixture(); diff --git a/apps/locallibrary/src/tui/ui.rs b/apps/locallibrary/src/tui/ui.rs index 0bb88250..45fa7482 100644 --- a/apps/locallibrary/src/tui/ui.rs +++ b/apps/locallibrary/src/tui/ui.rs @@ -2529,7 +2529,7 @@ fn draw_form(f: &mut Frame, app: &mut App, area: Rect) { let r = popup(area, w, lines.len() as u16 + 2, None); let keys = if selected.choices().is_empty() { "" } else { "←→ change · " }; let then = match form.then { - super::form::For::Run(_) => "run", + super::form::For::Run(_) | super::form::For::Export { .. } => "run", super::form::For::Settings(_) => "save", }; let b = block(title(&form.title, true), true) @@ -2711,6 +2711,40 @@ fn undo_lines(c: &super::edits::Confirm, lines: &mut Vec>) { } } +/// A plugin's export, as `export --dry-run` shows it: what it writes and what goes in, its first +/// files (what each holds, and those there already, by their place in the output's folder), what +/// the format can't hold, then problems with the library. +fn export_lines(c: &super::edits::Confirm, lines: &mut Vec>) { + use crate::export_cmd::{FILES_SHOWN, about}; + let Some(p) = c.export() else { return }; + // The summary says where (`write 2 file(s) to ...`). + for s in &p.summary { + lines.push(Line::from(vec![Span::styled("• ", fg(theme::CYAN)), Span::styled(s.clone(), bold(theme::FG))])); + } + let dir = p.plan.output.parent().unwrap_or(&p.plan.output); + for f in p.plan.files.iter().take(FILES_SHOWN) { + let path = f.file.path.strip_prefix(dir).unwrap_or(&f.file.path).display().to_string(); + let mut spans = vec![Span::styled(format!(" {path} "), text()), Span::styled(about(f), dim())]; + if f.exists { + spans.push(Span::styled(" (already there)", fg(theme::AMBER))); + } + lines.push(Line::from(spans)); + } + if p.plan.files.len() > FILES_SHOWN { + lines.push(Line::styled(format!(" and {} more", p.plan.files.len() - FILES_SHOWN), dim())); + } + for n in &p.plan.notes { + lines.push(Line::styled(n.clone(), dim())); + } + for w in &p.warnings { + lines.push(Line::styled(format!("⚠ {w}"), fg(theme::AMBER))); + } + if p.destructive { + lines.push(Line::raw("")); + lines.push(Line::styled("This replaces files already there.", fg(theme::AMBER))); + } +} + fn draw_confirm(f: &mut Frame, app: &mut App, area: Rect) { let Some(Modal::Confirm(c)) = &mut app.modal else { return }; let wording = super::edits::wording(&*c.plan, &c.drive); @@ -2722,7 +2756,7 @@ fn draw_confirm(f: &mut Frame, app: &mut App, area: Rect) { Span::styled(p.databases.join(" and "), text()), ])); // A plugin's operation: what the plugin says it does first. - let by = super::plugins::planned_by(c.plan.operation()); + let by = super::plugins::planned_by(c.plan.operation(), false); if let Some((by, said)) = by.zip(c.plan.summary().first()) { lines.push(Line::from(vec![ Span::styled(format!("{by}: "), bold(theme::CYAN)), @@ -2744,6 +2778,7 @@ fn draw_confirm(f: &mut Frame, app: &mut App, area: Rect) { )); } None if c.undo().is_some() => undo_lines(c, &mut lines), + None if c.export().is_some() => export_lines(c, &mut lines), None => { let plan = &c.plan; lines.push(Line::from(vec![ diff --git a/crates/locallibrary/examples/test-plugin/main.rs b/crates/locallibrary/examples/test-plugin/main.rs index 0ae941de..9bc6a6db 100644 --- a/crates/locallibrary/examples/test-plugin/main.rs +++ b/crates/locallibrary/examples/test-plugin/main.rs @@ -3,12 +3,12 @@ //! with `message` and `fields`), answers an environment variable (`env`), takes this many //! milliseconds to stop once its input closes (`linger`), writes a file once it has stopped //! (`stopped`), exits (`exit`), never stops (`stuck`), answers its context (`context`) or calls -//! back (`call` with `params`) when its params say so; its operation answers -//! the `edits` (and `inputs`) it's given; an analyzer named `order` answers fields `a` then `b`, -//! or fails naming an empty audio file; and its exporter would write to `outside`, or writes -//! `extra` too without saying so first. `--manifest ` serves another manifest, -//! `--protocol ` answers `hello` with another protocol and stops, `--silent` never -//! answers, and `--wrong-fields` answers fields of the wrong type. +//! back (`call` with `params`) when its params say so; its operation answers the `edits` (and +//! `inputs`) it's given; an analyzer named `order` answers fields `a` then `b`, or fails naming +//! an empty audio file; and its exporter names the playlists it's given in a note, and would +//! write to `outside`, or writes `extra` too without saying so first. `--manifest ` serves +//! another manifest, `--protocol ` answers `hello` with another protocol and stops, +//! `--silent` never answers, and `--wrong-fields` answers fields of the wrong type. mod plugin; diff --git a/crates/locallibrary/examples/test-plugin/plugin.rs b/crates/locallibrary/examples/test-plugin/plugin.rs index 479459c6..fad50a62 100644 --- a/crates/locallibrary/examples/test-plugin/plugin.rs +++ b/crates/locallibrary/examples/test-plugin/plugin.rs @@ -103,7 +103,11 @@ impl Plugin for Test { return Ok(Exported { files: vec![file], ..Exported::default() }); } let extra = req.options["extra"].as_str().filter(|_| !req.dry_run).map(std::path::PathBuf::from); + let playlists = req.source.playlists.clone(); let mut exported = echo::Echo.write_export(req, host)?; + if !playlists.is_empty() { + exported.notes.push(format!("playlists: {}", playlists.join(", "))); + } if let Some(extra) = extra { std::fs::write(&extra, "more").map_err(|e| RpcError::failed("io", e.to_string()))?; exported.files.push(ExportedFile { path: extra, what: String::new(), bytes: 4 }); From e98f3e9f7f09240a71c3a9d6f8451a6c8bce9d29 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:59:29 -0400 Subject: [PATCH 7/8] tui plugin tests: the missing tracklist is a path the platform names, so the error is checked on Windows too --- apps/locallibrary/src/tui/plugins/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/locallibrary/src/tui/plugins/tests.rs b/apps/locallibrary/src/tui/plugins/tests.rs index ae9da5e7..0dc9d6cb 100644 --- a/apps/locallibrary/src/tui/plugins/tests.rs +++ b/apps/locallibrary/src/tui/plugins/tests.rs @@ -321,11 +321,13 @@ fn planning_an_operation_is_stopped_with_esc() { press(&mut app, ":make a playlist…\nKept\n"); wait(&mut app, |a| a.job.is_none()); assert_eq!((app.modal.is_none(), last_message(&app)), (true, "nothing to change")); - press(&mut app, ":make a playlist from a tracklist\n/nowhere/list.m3u\tFriday\n"); + // A file that isn't there, named as the platform names it. + let list = dir.path().join("nowhere").join("list.m3u"); + press(&mut app, &format!(":make a playlist from a tracklist\n{}\tFriday\n", list.display())); wait(&mut app, |a| a.job.is_none()); assert!(app.modal.is_none()); let (said, level) = app.toast.clone().map(|t| (t.0, t.1)).unwrap(); - assert!(level == Level::Error && said.contains("/nowhere/list.m3u"), "{said}"); + assert!(level == Level::Error && said.contains(&list.display().to_string()), "{said}"); } /// A plan is a plugin's when its operation is one of a plugin's operations or exports, by the From ea9830435e4b88cdf6a8623a47c316e40dda25a1 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:33:47 -0400 Subject: [PATCH 8/8] tui form tests: ~ is checked against the home folder as the platform names it --- apps/locallibrary/src/tui/form/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/locallibrary/src/tui/form/tests.rs b/apps/locallibrary/src/tui/form/tests.rs index 444e32f7..d846b57b 100644 --- a/apps/locallibrary/src/tui/form/tests.rs +++ b/apps/locallibrary/src/tui/form/tests.rs @@ -76,12 +76,12 @@ fn what_is_typed_becomes_the_params() { let mut f = form( r#"params = { type = "object", properties = { bpm = { type = "number" }, ids = { type = "array", items = { type = "integer" } }, pick = { type = "object", additionalProperties = { type = "integer" } }, file = { type = "string", x-path = true }, note = { type = "string" } } }"#, ); - let home = std::env::var("HOME").unwrap_or_default(); + let home = std::env::home_dir().unwrap(); let params = filled(&mut f, &["120.5", "1, #2,3,", "1=4, 2=5", "~/list.m3u", ""]).unwrap(); assert_eq!(params["bpm"], json!(120.5)); assert_eq!(params["ids"], json!([1, 2, 3])); assert_eq!(params["pick"], json!({ "1": 4, "2": 5 })); - assert_eq!(params["file"], json!(format!("{home}/list.m3u"))); + assert_eq!(params["file"], json!(home.join("list.m3u"))); assert!(params.get("note").is_none()); assert_eq!(filled(&mut f, &["fast"]).unwrap_err(), (0, "bpm takes a number, not \"fast\"".into())); assert_eq!(filled(&mut f, &["", "1", "4"]).unwrap_err(), (2, "pick takes KEY=VALUE, not \"4\"".into()));