diff --git a/docs/rfcs/RFC-012-library-browser.md b/docs/rfcs/RFC-012-library-browser.md index 5354b051..8f353fba 100644 --- a/docs/rfcs/RFC-012-library-browser.md +++ b/docs/rfcs/RFC-012-library-browser.md @@ -1,16 +1,20 @@ # RFC-012 — The library browser: folders as a view, filing as metadata - **Status**: Proposed -- **Implemented by**: in progress, in the order of "The order of the work" - below: [#298](https://github.com/InstaZDLL/waveflow-server/pull/298) (the +- **Implemented by**: every step of "The order of the work" below, in that + order, after this RFC itself ([#297](https://github.com/InstaZDLL/waveflow-server/pull/297)): + [#298](https://github.com/InstaZDLL/waveflow-server/pull/298) (the context menu and Properties, with `relative_path` on the track), [#299](https://github.com/InstaZDLL/waveflow-server/pull/299) (download, `?download=1` on the stream ticket), [#300](https://github.com/InstaZDLL/waveflow-server/pull/300) (uploaded lyrics: `track_lyrics_upload`, the TTML parser, the order, and the lyrics - download) and the pull request adding the disk tree (`GET - /libraries/{id}/tree` and the Folders page). The *Status* field above never - flips in this project; this line is the one that says what is running. + download), [#301](https://github.com/InstaZDLL/waveflow-server/pull/301) (the + disk tree, `GET /libraries/{id}/tree`, and the Folders page) and the pull + request adding the filed tree (`library_folder`, `track_placement`, their + routes, "Move to folder…", drag and drop, and uploads into a folder). The + *Status* field above never flips in this project; this line is the one that + says what is running. - **Date**: 2026-10-21 - **Revised**: 2026-10-21, after review on #297: the **Received** folder's contract, the depth check on a move, the folder cap that bounds the tree's @@ -318,11 +322,11 @@ library or a track the caller may not see. | Route | What | | --- | --- | | `GET /libraries/{id}/tree?path=` | One level of the disk tree: sub-folders with their track counts, and the tracks directly in `path` (empty for the root; `.waveflow-managed` for **Received**). Paged, like every listing. | -| `GET /libraries/{id}/folders` | The whole filed tree, without tracks, as one flat list of `id`, `parent_id`, `name` and the count of tracks filed directly in each. Not paged: bounded by the 5,000-folder cap of decision 2, which is what lets a client draw the tree from one answer. | +| `GET /libraries/{id}/folders` | The whole filed tree, without tracks, as one flat list of `id`, `parent_id`, `name` and the count of tracks filed directly in each, under `folders`, beside `unfiled_track_count` for the root's unfiled entry. Not paged: bounded by the 5,000-folder cap of decision 2, which is what lets a client draw the tree from one answer. | | `POST /libraries/{id}/folders` | Create: `name`, `parent_id`. | | `PATCH /folders/{id}` | Rename, or move under another `parent_id` (or to the root); `422` past the depth cap for the whole sub-tree, or on a cycle. | | `DELETE /folders/{id}` | Delete with its sub-folders; returns the number of tracks unfiled. | -| `GET /folders/{id}/tracks` | The tracks filed directly in a folder. Paged. `unfiled` stands for the root's unfiled entry. | +| `GET /libraries/{id}/folders/{folder}/tracks` | The tracks filed directly in a folder. Paged. `unfiled` in place of a folder id stands for the root's unfiled entry, which is why the library is in the path: unfiled names no folder to find it by. | | `PUT /libraries/{id}/placements` | File up to 500 tracks into one folder, or unfile them (`folder_id: null`). **All or nothing**: the folder and every track are checked before anything is written, in one transaction under the writer gate; any track the caller may not see, or outside the library, answers `404`, a folder outside it `404`, more than 500 `422`, and no placement changes. | | `PUT /tracks/{id}/lyrics/{format}` | Upload `lrc`, `txt` or `ttml`. | | `DELETE /tracks/{id}/lyrics/{format}` | Remove an upload. | diff --git a/migrations-v2/20261021010000_library_folder.sql b/migrations-v2/20261021010000_library_folder.sql new file mode 100644 index 00000000..06e85b7d --- /dev/null +++ b/migrations-v2/20261021010000_library_folder.sql @@ -0,0 +1,48 @@ +-- Filed folders, and where each track is filed (RFC-012 decision 2). +-- +-- A filed folder exists only here: nothing on disk is created, moved, renamed +-- or deleted by anything that writes these tables. The disk tree is derived +-- from `track.relative_path` and is never stored. +-- +-- `library_id` rides on every row so a parent, a folder and a track can only +-- ever meet inside one library: the composite keys make a cross-library +-- reference impossible to write rather than merely checked. +CREATE TABLE library_folder ( + id TEXT PRIMARY KEY, + library_id TEXT NOT NULL REFERENCES library(id) ON DELETE CASCADE, + -- NULL at the root. Deleting a folder takes its sub-folders with it. + parent_id TEXT, + name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120), + -- The name folded for comparison, written by the server: SQLite's NOCASE + -- folds ASCII only, and "Été" and "été" must collide among siblings. + name_key TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (id, library_id), + FOREIGN KEY (parent_id, library_id) + REFERENCES library_folder(id, library_id) ON DELETE CASCADE +) STRICT; + +-- Sibling names are unique, the root counting as one parent. The service +-- checks first, to answer cleanly; this is what holds if it ever did not. +CREATE UNIQUE INDEX library_folder_sibling_name + ON library_folder(library_id, ifnull(parent_id, ''), name_key); +CREATE INDEX library_folder_parent ON library_folder(parent_id); + +-- At most one filed folder per track: the track id is the key. A track in no +-- folder is unfiled and has no row. A track deleted by a scan, and a folder +-- deleted by its owner, take their placements with them. +CREATE TABLE track_placement ( + track_id TEXT PRIMARY KEY, + library_id TEXT NOT NULL, + folder_id TEXT NOT NULL, + placed_by TEXT NOT NULL, + placed_at INTEGER NOT NULL, + FOREIGN KEY (track_id, library_id) + REFERENCES track(id, library_id) ON DELETE CASCADE, + FOREIGN KEY (folder_id, library_id) + REFERENCES library_folder(id, library_id) ON DELETE CASCADE +) STRICT; + +CREATE INDEX track_placement_folder ON track_placement(folder_id); diff --git a/src/api/folders.rs b/src/api/folders.rs new file mode 100644 index 00000000..0405602d --- /dev/null +++ b/src/api/folders.rs @@ -0,0 +1,144 @@ +//! Filed folders and placements (RFC-012 decisions 2 and 3). +//! +//! Handlers orchestrate HTTP only; the rules live in `DomainServices`. Reads +//! are open to any member, writes to owners and managers, and everyone else +//! gets the same `404` a missing folder would. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, + /// `null` or absent for the root. + #[serde(default)] + pub parent_id: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct PlacementRequest { + /// Up to 500 tracks of the library. + pub track_ids: Vec, + /// The folder to file them in, or `null` to unfile them. + pub folder_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FolderTracksQuery { + pub offset: Option, + pub limit: Option, +} + +/// The body a placement may carry: the most tracks one request files, each +/// as a quoted UUID and its comma, and room for the envelope. +pub(super) fn placement_body_limit() -> usize { + crate::services::MAX_PLACEMENTS * 48 + 1024 +} + +/// The whole filed tree of a library, flat, with the count of unfiled tracks. +#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/folders", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 200, body = crate::services::FiledTree), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn list_folders( + State(state): State, + Path(library_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .filed_tree(user.id, library_id) + .await + .map(Json) + .map_err(service_error) +} + +/// Creates a folder at the root or under `parent_id`. `409` for a sibling of +/// the same name, `422` past the depth or folder cap. +#[utoipa::path(post, path = "/api/v2/libraries/{library_id}/folders", tag = "catalog", params(("library_id" = Uuid, Path)), request_body = CreateFolderRequest, responses((status = 201, body = crate::services::FiledFolder), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_folder( + State(state): State, + Path(library_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .create_folder(user.id, library_id, &request.name, request.parent_id) + .await + .map(|folder| (StatusCode::CREATED, Json(folder))) + .map_err(service_error) +} + +/// Renames a folder, moves it (`parent_id: null` for the root), or both. +#[utoipa::path(patch, path = "/api/v2/folders/{folder_id}", tag = "catalog", params(("folder_id" = Uuid, Path)), request_body = crate::services::FolderPatch, responses((status = 200, body = crate::services::FiledFolder), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn update_folder( + State(state): State, + Path(folder_id): Path, + headers: HeaderMap, + Json(patch): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .update_folder(user.id, folder_id, patch) + .await + .map(Json) + .map_err(service_error) +} + +/// Deletes a folder and its sub-folders; their tracks become unfiled. +#[utoipa::path(delete, path = "/api/v2/folders/{folder_id}", tag = "catalog", params(("folder_id" = Uuid, Path)), responses((status = 200, body = crate::services::FolderRemoved), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_folder( + State(state): State, + Path(folder_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .delete_folder(user.id, folder_id) + .await + .map(Json) + .map_err(service_error) +} + +/// The tracks filed directly in a folder, paged; `unfiled` in place of an id +/// for the library's unfiled tracks. +#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/folders/{folder}/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("folder" = String, Path, description = "a folder id, or unfiled"), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::SongItem]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn folder_tracks( + State(state): State, + Path((library_id, folder)): Path<(Uuid, String)>, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let folder = match folder.as_str() { + "unfiled" => None, + id => Some(Uuid::parse_str(id).map_err(|_| ApiError::NotFound)?), + }; + let page = + crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; + state + .services + .folder_tracks(user.id, library_id, folder, page) + .await + .map(Json) + .map_err(service_error) +} + +/// Files up to 500 tracks into one folder, or unfiles them with +/// `folder_id: null`. All or nothing. +#[utoipa::path(put, path = "/api/v2/libraries/{library_id}/placements", tag = "catalog", params(("library_id" = Uuid, Path)), request_body = PlacementRequest, responses((status = 200, body = crate::services::Placed), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 413, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn place_tracks( + State(state): State, + Path(library_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .place_tracks(user.id, library_id, &request.track_ids, request.folder_id) + .await + .map(Json) + .map_err(service_error) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index bd42bf0f..8c013364 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,6 +28,7 @@ mod bookmarks; mod catalog; mod error; mod favorites; +mod folders; mod libraries; mod oauth; mod playback; @@ -51,6 +52,7 @@ pub use bookmarks::*; pub use catalog::*; pub use error::*; pub use favorites::*; +pub use folders::*; pub use libraries::*; pub use oauth::*; pub use playback::*; @@ -114,6 +116,24 @@ pub fn router(state: AppState) -> Router { .route("/api/v2/scans/{scan_id}/events", get(scan_events)) .route("/api/v2/libraries/{library_id}/tracks", get(list_tracks)) .route("/api/v2/libraries/{library_id}/tree", get(library_tree)) + .route( + "/api/v2/libraries/{library_id}/folders", + get(list_folders).post(create_folder), + ) + .route( + "/api/v2/libraries/{library_id}/folders/{folder}/tracks", + get(folder_tracks), + ) + .route( + "/api/v2/libraries/{library_id}/placements", + put(place_tracks).layer(axum::extract::DefaultBodyLimit::max( + folders::placement_body_limit(), + )), + ) + .route( + "/api/v2/folders/{folder_id}", + axum::routing::patch(update_folder).delete(delete_folder), + ) .route("/api/v2/libraries/{library_id}/events", get(library_events)) .route( "/api/v2/libraries/{library_id}/events/ack", diff --git a/src/lib.rs b/src/lib.rs index 82455d40..6e01d1fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,6 +140,12 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::commit_upload, api::list_tracks, api::library_tree, + api::list_folders, + api::create_folder, + api::update_folder, + api::delete_folder, + api::folder_tracks, + api::place_tracks, api::get_track, api::update_track, api::get_track_lyrics, @@ -300,6 +306,13 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { services::DiskListing, services::DiskFolder, services::DiskTrack, + services::FiledFolder, + services::FiledTree, + services::FolderPatch, + services::FolderRemoved, + services::Placed, + api::CreateFolderRequest, + api::PlacementRequest, services::TrackMetadataPatch, services::UploadOffer, services::UploadVerdict, diff --git a/src/services/library_folders.rs b/src/services/library_folders.rs new file mode 100644 index 00000000..b416c136 --- /dev/null +++ b/src/services/library_folders.rs @@ -0,0 +1,620 @@ +//! Filed folders: a library sorted by its owner and managers, in the database +//! only (RFC-012 decisions 2 and 3). +//! +//! A folder is a place, not a collection: a track sits in at most one, and +//! moving it takes it out of the first. Nothing here touches a file. Every +//! write is gated like a metadata correction — owner and manager — and runs in +//! one transaction under the writer gate, so the caps and the checks it makes +//! hold against a racing write. Any member reads. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use sqlx::{Row, Sqlite, Transaction}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{ + attach_song_relations, song_from_row, BrowsePage, DomainServices, ServiceError, SongItem, +}; +use crate::authentication::now_ms; +use crate::database::LibraryRole; + +/// The deepest a folder may sit: the root is 0, a folder at the root is 1. +pub const MAX_FOLDER_DEPTH: i64 = 16; +/// The most folders one library holds — what keeps the tree one bounded +/// answer. +pub const MAX_FOLDERS_PER_LIBRARY: i64 = 5_000; +/// The most tracks one placement request files. +pub const MAX_PLACEMENTS: usize = 500; +const MAX_NAME_CHARS: usize = 120; +/// Past any depth the caps allow; it only keeps a walk finite. +const WALK_LIMIT: i64 = 64; + +/// A filed folder, without its tracks. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct FiledFolder { + pub id: Uuid, + /// `null` at the root. + pub parent_id: Option, + pub name: String, + /// The available tracks filed directly in it. + pub track_count: i64, +} + +/// A library's whole filed tree, as one flat list a client draws from. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct FiledTree { + /// Every folder, siblings by name, case-insensitively. + pub folders: Vec, + /// The available tracks filed nowhere: the root's **Unfiled** entry. + pub unfiled_track_count: i64, +} + +/// What deleting a folder did. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct FolderRemoved { + /// The tracks that were filed in it or under it, and are now unfiled. + pub unfiled: i64, +} + +/// What a placement did. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Placed { + /// The tracks filed, or unfiled when the folder was `null`. + pub placed: i64, +} + +/// A rename, a move, or both. `parent_id` absent leaves the folder where it +/// is; `null` moves it to the root. +#[derive(Debug, Clone, Default, Deserialize, ToSchema)] +pub struct FolderPatch { + pub name: Option, + #[serde(default, deserialize_with = "super::present")] + #[schema(value_type = Option, nullable)] + pub parent_id: Option>, +} + +/// A name as stored, and the key siblings are compared by. `None` when it is +/// not a name: empty once trimmed, longer than 120 characters, or holding a +/// control character. A `/` is allowed — no path is built from it. +fn folder_name(name: &str) -> Option<(String, String)> { + let name = name.trim(); + let length = name.chars().count(); + if length == 0 || length > MAX_NAME_CHARS || name.chars().any(char::is_control) { + return None; + } + Some((name.to_owned(), name.to_lowercase())) +} + +/// The library a folder belongs to and the caller's role in it; `NotFound` +/// for a folder in no library the caller is a member of. +async fn folder_access( + tx: &mut Transaction<'_, Sqlite>, + user_id: Uuid, + folder_id: Uuid, +) -> Result<(String, LibraryRole), ServiceError> { + let row = sqlx::query( + "SELECT f.library_id, m.role FROM library_folder f \ + JOIN library_member m ON m.library_id=f.library_id \ + WHERE m.user_id=? AND f.id=?", + ) + .bind(user_id.to_string()) + .bind(folder_id.to_string()) + .fetch_optional(&mut **tx) + .await? + .ok_or(ServiceError::NotFound)?; + let role = LibraryRole::from_str(row.try_get::<&str, _>("role")?) + .map_err(|_| ServiceError::Invalid)?; + Ok((row.try_get("library_id")?, role)) +} + +/// The caller's role in a library; `NotFound` for one they are not in. +async fn library_role( + tx: &mut Transaction<'_, Sqlite>, + user_id: Uuid, + library_id: Uuid, +) -> Result { + let role: String = + sqlx::query_scalar("SELECT role FROM library_member WHERE user_id=? AND library_id=?") + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .fetch_optional(&mut **tx) + .await? + .ok_or(ServiceError::NotFound)?; + LibraryRole::from_str(&role).map_err(|_| ServiceError::Invalid) +} + +fn may_file(role: LibraryRole) -> Result<(), ServiceError> { + if role.may_write_metadata() { + Ok(()) + } else { + Err(ServiceError::Forbidden) + } +} + +/// How deep a folder sits: 1 at the root. +async fn depth_of(tx: &mut Transaction<'_, Sqlite>, folder_id: &str) -> Result { + Ok(sqlx::query_scalar( + "WITH RECURSIVE up(id, parent_id, depth) AS ( \ + SELECT id, parent_id, 1 FROM library_folder WHERE id=? \ + UNION ALL \ + SELECT f.id, f.parent_id, up.depth + 1 FROM library_folder f \ + JOIN up ON f.id=up.parent_id WHERE up.depth < ?) \ + SELECT COALESCE(MAX(depth), 0) FROM up", + ) + .bind(folder_id) + .bind(WALK_LIMIT) + .fetch_one(&mut **tx) + .await?) +} + +/// A folder's sub-tree: its height, the folder itself counting 1, and whether +/// `candidate` is in it. +async fn subtree( + tx: &mut Transaction<'_, Sqlite>, + folder_id: &str, + candidate: Option<&str>, +) -> Result<(i64, bool), ServiceError> { + let row = sqlx::query( + "WITH RECURSIVE down(id, level) AS ( \ + SELECT id, 1 FROM library_folder WHERE id=? \ + UNION ALL \ + SELECT f.id, down.level + 1 FROM library_folder f \ + JOIN down ON f.parent_id=down.id WHERE down.level < ?) \ + SELECT COALESCE(MAX(level), 0) AS height, \ + COALESCE(SUM(id = ?), 0) AS holds FROM down", + ) + .bind(folder_id) + .bind(WALK_LIMIT) + .bind(candidate) + .fetch_one(&mut **tx) + .await?; + Ok((row.try_get("height")?, row.try_get::("holds")? > 0)) +} + +/// `Conflict` when a sibling under `parent` already answers to `key`. +async fn sibling_free( + tx: &mut Transaction<'_, Sqlite>, + library_id: &str, + parent: Option<&str>, + key: &str, + except: Option<&str>, +) -> Result<(), ServiceError> { + let taken: Option = sqlx::query_scalar( + "SELECT id FROM library_folder \ + WHERE library_id=? AND ifnull(parent_id, '')=ifnull(?, '') AND name_key=? \ + AND (? IS NULL OR id<>?)", + ) + .bind(library_id) + .bind(parent) + .bind(key) + .bind(except) + .bind(except) + .fetch_optional(&mut **tx) + .await?; + if taken.is_some() { + Err(ServiceError::Conflict) + } else { + Ok(()) + } +} + +/// `NotFound` unless `folder_id` is a folder of `library_id`. +async fn folder_in_library( + tx: &mut Transaction<'_, Sqlite>, + library_id: &str, + folder_id: &str, +) -> Result<(), ServiceError> { + sqlx::query_scalar::<_, String>("SELECT id FROM library_folder WHERE id=? AND library_id=?") + .bind(folder_id) + .bind(library_id) + .fetch_optional(&mut **tx) + .await? + .map(|_| ()) + .ok_or(ServiceError::NotFound) +} + +async fn folder_row( + tx: &mut Transaction<'_, Sqlite>, + folder_id: &str, +) -> Result { + let row = sqlx::query( + "SELECT f.id, f.parent_id, f.name, \ + (SELECT COUNT(*) FROM track_placement p JOIN track t ON t.id=p.track_id \ + WHERE p.folder_id=f.id AND t.is_available=1) AS track_count \ + FROM library_folder f WHERE f.id=?", + ) + .bind(folder_id) + .fetch_one(&mut **tx) + .await?; + filed_folder(&row) +} + +fn filed_folder(row: &sqlx::sqlite::SqliteRow) -> Result { + let id: String = row.try_get("id")?; + let parent: Option = row.try_get("parent_id")?; + Ok(FiledFolder { + id: Uuid::parse_str(&id).map_err(|_| ServiceError::Invalid)?, + parent_id: parent + .map(|parent| Uuid::parse_str(&parent)) + .transpose() + .map_err(|_| ServiceError::Invalid)?, + name: row.try_get("name")?, + track_count: row.try_get("track_count")?, + }) +} + +impl DomainServices { + /// A library's whole filed tree, flat: bounded by the folder cap, so it + /// is one answer and never a page. Any member reads it. + pub async fn filed_tree( + &self, + user_id: Uuid, + library_id: Uuid, + ) -> Result { + if self + .db + .library_for_user(user_id, library_id) + .await? + .is_none() + { + return Err(ServiceError::NotFound); + } + let rows = sqlx::query( + "SELECT f.id, f.parent_id, f.name, \ + (SELECT COUNT(*) FROM track_placement p JOIN track t ON t.id=p.track_id \ + WHERE p.folder_id=f.id AND t.is_available=1) AS track_count \ + FROM library_folder f WHERE f.library_id=? \ + ORDER BY f.name_key, f.name, f.id", + ) + .bind(library_id.to_string()) + .fetch_all(self.db.pool()) + .await?; + let folders = rows + .iter() + .map(filed_folder) + .collect::, _>>()?; + let unfiled_track_count = sqlx::query_scalar( + "SELECT COUNT(*) FROM track t WHERE t.library_id=? AND t.is_available=1 \ + AND NOT EXISTS (SELECT 1 FROM track_placement p WHERE p.track_id=t.id)", + ) + .bind(library_id.to_string()) + .fetch_one(self.db.pool()) + .await?; + Ok(FiledTree { + folders, + unfiled_track_count, + }) + } + + /// Create a folder at the root, or under `parent_id` in the same library. + /// + /// `Invalid` for a name that is not one, past the depth cap, or past the + /// library's folder cap; `Conflict` for a sibling of the same name; + /// `NotFound` for a parent elsewhere. All checked in the transaction that + /// writes, under the writer gate. + pub async fn create_folder( + &self, + user_id: Uuid, + library_id: Uuid, + name: &str, + parent_id: Option, + ) -> Result { + let (name, key) = folder_name(name).ok_or(ServiceError::Invalid)?; + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + may_file(library_role(&mut tx, user_id, library_id).await?)?; + let library = library_id.to_string(); + let parent = parent_id.map(|parent| parent.to_string()); + if let Some(parent) = parent.as_deref() { + folder_in_library(&mut tx, &library, parent).await?; + if depth_of(&mut tx, parent).await? >= MAX_FOLDER_DEPTH { + return Err(ServiceError::Invalid); + } + } + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM library_folder WHERE library_id=?") + .bind(&library) + .fetch_one(&mut *tx) + .await?; + if count >= MAX_FOLDERS_PER_LIBRARY { + return Err(ServiceError::Invalid); + } + sibling_free(&mut tx, &library, parent.as_deref(), &key, None).await?; + let id = Uuid::new_v4().to_string(); + let now = now_ms(); + sqlx::query( + "INSERT INTO library_folder \ + (id, library_id, parent_id, name, name_key, created_by, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&library) + .bind(parent.as_deref()) + .bind(&name) + .bind(&key) + .bind(user_id.to_string()) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + let folder = folder_row(&mut tx, &id).await?; + tx.commit().await?; + Ok(folder) + } + + /// Rename a folder, move it, or both. + /// + /// A move is checked for the whole sub-tree it carries: the new parent's + /// depth plus the sub-tree's height must stay within the cap, and a move + /// under itself or one of its descendants is refused. Both answer + /// `Invalid`, and neither is repaired. + pub async fn update_folder( + &self, + user_id: Uuid, + folder_id: Uuid, + patch: FolderPatch, + ) -> Result { + let renamed = match patch.name.as_deref() { + Some(name) => Some(folder_name(name).ok_or(ServiceError::Invalid)?), + None => None, + }; + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + let (library, role) = folder_access(&mut tx, user_id, folder_id).await?; + may_file(role)?; + let id = folder_id.to_string(); + let current = sqlx::query("SELECT parent_id, name_key FROM library_folder WHERE id=?") + .bind(&id) + .fetch_one(&mut *tx) + .await?; + let current_parent: Option = current.try_get("parent_id")?; + let current_key: String = current.try_get("name_key")?; + + let parent = match patch.parent_id { + None => current_parent, + Some(target) => { + let target = target.map(|target| target.to_string()); + let (height, holds) = subtree(&mut tx, &id, target.as_deref()).await?; + let base = match target.as_deref() { + Some(target) => { + folder_in_library(&mut tx, &library, target).await?; + if holds { + return Err(ServiceError::Invalid); + } + depth_of(&mut tx, target).await? + } + None => 0, + }; + if base + height > MAX_FOLDER_DEPTH { + return Err(ServiceError::Invalid); + } + target + } + }; + let key = renamed + .as_ref() + .map_or(current_key.as_str(), |(_, key)| key.as_str()); + sibling_free(&mut tx, &library, parent.as_deref(), key, Some(&id)).await?; + sqlx::query( + "UPDATE library_folder SET parent_id=?, \ + name=COALESCE(?, name), name_key=COALESCE(?, name_key), updated_at=? \ + WHERE id=?", + ) + .bind(parent.as_deref()) + .bind(renamed.as_ref().map(|(name, _)| name.as_str())) + .bind(renamed.as_ref().map(|(_, key)| key.as_str())) + .bind(now_ms()) + .bind(&id) + .execute(&mut *tx) + .await?; + let folder = folder_row(&mut tx, &id).await?; + tx.commit().await?; + Ok(folder) + } + + /// Delete a folder and its sub-folders. Every track filed in or under it + /// becomes unfiled; nothing is filed elsewhere and nothing on disk moves. + pub async fn delete_folder( + &self, + user_id: Uuid, + folder_id: Uuid, + ) -> Result { + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + let (_, role) = folder_access(&mut tx, user_id, folder_id).await?; + may_file(role)?; + let id = folder_id.to_string(); + let unfiled: i64 = sqlx::query_scalar( + "WITH RECURSIVE down(id, level) AS ( \ + SELECT id, 1 FROM library_folder WHERE id=? \ + UNION ALL \ + SELECT f.id, down.level + 1 FROM library_folder f \ + JOIN down ON f.parent_id=down.id WHERE down.level < ?) \ + SELECT COUNT(*) FROM track_placement WHERE folder_id IN (SELECT id FROM down)", + ) + .bind(&id) + .bind(WALK_LIMIT) + .fetch_one(&mut *tx) + .await?; + // The sub-folders and the placements go by their foreign keys. + sqlx::query("DELETE FROM library_folder WHERE id=?") + .bind(&id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(FolderRemoved { unfiled }) + } + + /// The tracks filed directly in a folder, or with `folder_id` `None` the + /// library's unfiled ones. Sorted by artist, album, disc and track, a + /// missing tag last. Any member reads; `NotFound` for anyone else. + pub async fn folder_tracks( + &self, + user_id: Uuid, + library_id: Uuid, + folder_id: Option, + page: BrowsePage, + ) -> Result, ServiceError> { + macro_rules! by_sleeve { + () => { + " ORDER BY t.artist_display COLLATE NOCASE NULLS LAST, \ + t.album_title COLLATE NOCASE NULLS LAST, \ + COALESCE(ovr.disc_number, t.disc_number) NULLS LAST, \ + COALESCE(ovr.track_number, t.track_number) NULLS LAST, \ + COALESCE(ovr.title, t.title) COLLATE NOCASE, t.id LIMIT ? OFFSET ?" + }; + } + let rows = match folder_id { + Some(folder) => { + let library: Option = sqlx::query_scalar( + "SELECT f.library_id FROM library_folder f \ + JOIN library_member m ON m.library_id=f.library_id \ + WHERE m.user_id=? AND f.id=? AND f.library_id=?", + ) + .bind(user_id.to_string()) + .bind(folder.to_string()) + .bind(library_id.to_string()) + .fetch_optional(self.db.pool()) + .await?; + if library.is_none() { + return Err(ServiceError::NotFound); + } + sqlx::query(concat!( + song_select!(), + " AND t.id IN (SELECT track_id FROM track_placement WHERE folder_id=?)", + by_sleeve!() + )) + .bind(user_id.to_string()) + .bind(folder.to_string()) + .bind(page.limit) + .bind(page.offset) + .fetch_all(self.db.pool()) + .await? + } + None => { + if self + .db + .library_for_user(user_id, library_id) + .await? + .is_none() + { + return Err(ServiceError::NotFound); + } + sqlx::query(concat!( + song_select!(), + " AND t.library_id=? \ + AND NOT EXISTS (SELECT 1 FROM track_placement p WHERE p.track_id=t.id)", + by_sleeve!() + )) + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .bind(page.limit) + .bind(page.offset) + .fetch_all(self.db.pool()) + .await? + } + }; + let mut songs = rows + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(songs) + } + + /// File up to 500 tracks into one folder, or unfile them with `None`. + /// + /// All or nothing: the folder and every track are checked before anything + /// is written, in one transaction under the writer gate. A track that is + /// not an available track of this library, or a folder of another, answers + /// `NotFound`; more than 500 answers `Invalid`. Filing a track that sits + /// in another folder moves it. + pub async fn place_tracks( + &self, + user_id: Uuid, + library_id: Uuid, + track_ids: &[Uuid], + folder_id: Option, + ) -> Result { + if track_ids.len() > MAX_PLACEMENTS { + return Err(ServiceError::Invalid); + } + let tracks: BTreeSet = track_ids.iter().map(Uuid::to_string).collect(); + let listed = serde_json::to_string(&tracks).map_err(|_| ServiceError::Invalid)?; + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + may_file(library_role(&mut tx, user_id, library_id).await?)?; + let library = library_id.to_string(); + if let Some(folder) = folder_id { + folder_in_library(&mut tx, &library, &folder.to_string()).await?; + } + let found: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM track WHERE library_id=? AND is_available=1 \ + AND id IN (SELECT value FROM json_each(?))", + ) + .bind(&library) + .bind(&listed) + .fetch_one(&mut *tx) + .await?; + if found != tracks.len() as i64 { + return Err(ServiceError::NotFound); + } + match folder_id { + Some(folder) => { + sqlx::query( + "INSERT INTO track_placement \ + (track_id, library_id, folder_id, placed_by, placed_at) \ + SELECT value, ?, ?, ?, ? FROM json_each(?) WHERE true \ + ON CONFLICT(track_id) DO UPDATE SET \ + folder_id=excluded.folder_id, placed_by=excluded.placed_by, \ + placed_at=excluded.placed_at", + ) + .bind(&library) + .bind(folder.to_string()) + .bind(user_id.to_string()) + .bind(now_ms()) + .bind(&listed) + .execute(&mut *tx) + .await?; + } + None => { + sqlx::query( + "DELETE FROM track_placement \ + WHERE library_id=? AND track_id IN (SELECT value FROM json_each(?))", + ) + .bind(&library) + .bind(&listed) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + Ok(Placed { + placed: tracks.len() as i64, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_folder_name_is_trimmed_bounded_and_folded() { + assert_eq!( + folder_name(" Été / 2024 "), + Some(("Été / 2024".into(), "été / 2024".into())) + ); + assert_eq!( + folder_name(&"é".repeat(120)).map(|(name, _)| name.chars().count()), + Some(120) + ); + for refused in ["", " ", "tab\there", "line\nbreak"] { + assert!(folder_name(refused).is_none(), "{refused:?}"); + } + assert!(folder_name(&"é".repeat(121)).is_none()); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 5b24b2b3..7285d6e8 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1364,6 +1364,7 @@ mod credentials; mod favorites; mod lastfm; mod library_events; +mod library_folders; mod library_tree; mod lyrics_uploads; mod playback; @@ -1394,6 +1395,10 @@ pub use lastfm::{ LastFmApproval, LastFmJourneyStart, LastFmSessionExchange, LASTFM_CALLBACK_PREFIX, LASTFM_JOURNEY_COOKIE, }; +pub use library_folders::{ + FiledFolder, FiledTree, FolderPatch, FolderRemoved, Placed, MAX_FOLDERS_PER_LIBRARY, + MAX_FOLDER_DEPTH, MAX_PLACEMENTS, +}; pub use library_tree::{DiskFolder, DiskListing, DiskTrack}; pub use lyrics_uploads::{LyricsFile, MAX_UPLOAD_BYTES as MAX_LYRICS_UPLOAD_BYTES}; pub use scrobbling::{ diff --git a/tests/native_api.rs b/tests/native_api.rs index dee40817..2471b471 100644 --- a/tests/native_api.rs +++ b/tests/native_api.rs @@ -4588,3 +4588,530 @@ async fn the_disk_tree_lists_one_level_of_the_indexed_paths_to_members_only() { ); } } + +/// Filed folders (RFC-012 decisions 2 and 3), through the router: names, +/// depth, cycles and the folder cap checked before anything is written, +/// placements all or nothing, a delete that unfiles, and a listener who reads +/// every bit of it and writes none. No file exists here at all: filing never +/// needs one. Passwords are drawn at run time, for CodeQL. +#[tokio::test] +async fn filed_folders_sort_a_library_in_the_database_only() { + let (_temp, config, state) = test_app().await; + let password = uuid::Uuid::new_v4().to_string(); + let hash = security::hash_password(&password).unwrap(); + let mut accounts = Vec::new(); + for name in ["filer-owner", "filer-listener", "filer-stranger"] { + accounts.push( + state + .db + .create_account(name, &hash, AccountRole::User, now_ms()) + .await + .unwrap(), + ); + } + let (owner, listener) = (accounts[0], accounts[1]); + let root = std::fs::canonicalize(&config.data_dir).unwrap(); + let mut libraries = Vec::new(); + let mut tracks = Vec::new(); + for (offset, name) in [(0, "Filed"), (10, "Elsewhere")] { + let library_id = state + .db + .create_library( + owner, + name, + &root.join(name), + LibraryVisibility::Private, + now_ms(), + ) + .await + .unwrap(); + let scan_id = state + .db + .create_scan_job(library_id, Some(owner), "manual") + .await + .unwrap(); + state.db.start_scan_job(scan_id, 5, false).await.unwrap(); + for index in 0..5 { + let title = format!("{name} {index}"); + let mut input = browse_input( + 30_000 + offset + index, + &title, + "Album", + "Artist", + Some(index as i64 + 1), + Some(1), + ); + input.relative_path = format!("{name}/{index}.flac"); + state + .db + .apply_catalog_track(library_id, scan_id, &input, None, false) + .await + .unwrap(); + } + state.db.finish_scan_job(scan_id, 0).await.unwrap(); + let ids: Vec = state + .db + .list_tracks_for_user(owner, library_id) + .await + .unwrap() + .into_iter() + .map(|track| track.id) + .collect(); + libraries.push(library_id); + tracks.push(ids); + } + let (library, elsewhere) = (libraries[0], libraries[1]); + state + .db + .add_library_member( + owner, + library, + listener, + waveflow_server::database::LibraryRole::Listener, + now_ms(), + ) + .await + .unwrap(); + // Sorted by title, so `t[0]` is "Filed 0". + let t = &tracks[0]; + let foreign_track = tracks[1][0]; + + let router = waveflow_server::app(&config, state.clone()); + let owner_token = login_token(&router, "filer-owner", &password).await; + let listener_token = login_token(&router, "filer-listener", &password).await; + let stranger_token = login_token(&router, "filer-stranger", &password).await; + let send = |method: Method, uri: String, token: &str, body: Option| { + let router = router.clone(); + let token = token.to_owned(); + async move { + let request = Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {token}")); + let request = match body { + Some(body) => request + .header("content-type", "application/json") + .body(Body::from(body.to_string())), + None => request.body(Body::empty()), + }; + router.oneshot(request.unwrap()).await.unwrap() + } + }; + let folders_uri = format!("/api/v2/libraries/{library}/folders"); + let placements_uri = format!("/api/v2/libraries/{library}/placements"); + let create = |token: &str, name: &str, parent: Option<&str>| { + send( + Method::POST, + folders_uri.clone(), + token, + Some(serde_json::json!({ "name": name, "parent_id": parent })), + ) + }; + let tree = || async { + json_body(send(Method::GET, folders_uri.clone(), &owner_token, None).await).await + }; + let count_of = |tree: &serde_json::Value, name: &str| -> Option { + tree["folders"] + .as_array() + .unwrap() + .iter() + .find(|folder| folder["name"] == name) + .map(|folder| folder["track_count"].as_i64().unwrap()) + }; + let titles = |songs: serde_json::Value| -> Vec { + songs + .as_array() + .unwrap() + .iter() + .map(|song| song["title"].as_str().unwrap().to_owned()) + .collect() + }; + + // A fresh library is entirely unfiled. + let fresh = tree().await; + assert_eq!(fresh["folders"], serde_json::json!([])); + assert_eq!(fresh["unfiled_track_count"], 5); + + // Names: trimmed, folded for siblings beyond ASCII, bounded, `/` allowed. + let rock = create(&owner_token, " Rock ", None).await; + assert_eq!(rock.status(), StatusCode::CREATED); + let rock = json_body(rock).await; + assert_eq!(rock["name"], "Rock"); + assert_eq!(rock["parent_id"], serde_json::Value::Null); + let rock_id = rock["id"].as_str().unwrap().to_owned(); + assert_eq!( + create(&owner_token, "ROCK", None).await.status(), + StatusCode::CONFLICT + ); + assert_eq!( + create(&owner_token, "Été", None).await.status(), + StatusCode::CREATED + ); + assert_eq!( + create(&owner_token, "ÉTÉ", None).await.status(), + StatusCode::CONFLICT + ); + assert_eq!( + create(&owner_token, "A/B", None).await.status(), + StatusCode::CREATED + ); + for refused in [" ".to_owned(), "x".repeat(121), "line\nbreak".to_owned()] { + assert_eq!( + create(&owner_token, &refused, None).await.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{refused:?}" + ); + } + let indie = json_body(create(&owner_token, "Indie", Some(&rock_id)).await).await; + let indie_id = indie["id"].as_str().unwrap().to_owned(); + assert_eq!(indie["parent_id"], rock_id.as_str()); + // A sibling elsewhere in the tree is no clash: "Indie" again, at the root. + assert_eq!( + create(&owner_token, "Indie", None).await.status(), + StatusCode::CREATED + ); + + // A listener reads and writes nothing; a stranger does not even read. + assert_eq!( + send(Method::GET, folders_uri.clone(), &listener_token, None) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + send(Method::GET, folders_uri.clone(), &stranger_token, None) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + create(&listener_token, "Mine", None).await.status(), + StatusCode::NOT_FOUND + ); + + // Placements: filed, then moved, a track in one folder at a time. + let place = |token: &str, ids: Vec, folder: Option<&str>| { + send( + Method::PUT, + placements_uri.clone(), + token, + Some(serde_json::json!({ "track_ids": ids, "folder_id": folder })), + ) + }; + let placed = place( + &owner_token, + vec![t[0].to_string(), t[1].to_string()], + Some(&indie_id), + ) + .await; + assert_eq!(placed.status(), StatusCode::OK); + assert_eq!(json_body(placed).await["placed"], 2); + let moved = place(&owner_token, vec![t[0].to_string()], Some(&rock_id)).await; + assert_eq!(moved.status(), StatusCode::OK); + let sorted = tree().await; + assert_eq!( + (count_of(&sorted, "Rock"), count_of(&sorted, "A/B")), + (Some(1), Some(0)) + ); + assert_eq!(sorted["unfiled_track_count"], 3); + let in_indie = send( + Method::GET, + format!("{folders_uri}/{indie_id}/tracks"), + &listener_token, + None, + ) + .await; + assert_eq!(titles(json_body(in_indie).await), ["Filed 1"]); + let unfiled = send( + Method::GET, + format!("{folders_uri}/unfiled/tracks"), + &listener_token, + None, + ) + .await; + assert_eq!( + titles(json_body(unfiled).await), + ["Filed 2", "Filed 3", "Filed 4"] + ); + assert_eq!( + send( + Method::GET, + format!("{folders_uri}/nonsense/tracks"), + &owner_token, + None + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + + // All or nothing: one track from another library, and nothing is filed. + let mixed = place( + &owner_token, + vec![t[2].to_string(), foreign_track.to_string()], + Some(&rock_id), + ) + .await; + assert_eq!(mixed.status(), StatusCode::NOT_FOUND); + // A folder of another library, the listener, and one track too many. + let foreign_folder = json_body( + send( + Method::POST, + format!("/api/v2/libraries/{elsewhere}/folders"), + &owner_token, + Some(serde_json::json!({ "name": "Theirs" })), + ) + .await, + ) + .await; + let foreign_folder = foreign_folder["id"].as_str().unwrap().to_owned(); + assert_eq!( + place(&owner_token, vec![t[2].to_string()], Some(&foreign_folder)) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + send( + Method::GET, + format!("{folders_uri}/{foreign_folder}/tracks"), + &owner_token, + None + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + place(&listener_token, vec![t[2].to_string()], Some(&rock_id)) + .await + .status(), + StatusCode::NOT_FOUND + ); + let many = |count: usize| { + (0..count) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>() + }; + // Five hundred ids fit the body, and name nothing here; one more is refused + // as a number before anything is looked up. + assert_eq!( + place(&owner_token, many(500), Some(&rock_id)) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + place(&owner_token, many(501), Some(&rock_id)) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!(tree().await["unfiled_track_count"], 3); + // Unfiling is a placement in no folder. + assert_eq!( + place(&owner_token, vec![t[1].to_string()], None) + .await + .status(), + StatusCode::OK + ); + assert_eq!(tree().await["unfiled_track_count"], 4); + place(&owner_token, vec![t[1].to_string()], Some(&indie_id)).await; + + // Moves: never under itself, and the whole sub-tree within the cap. + let patch = |token: &str, id: &str, body: serde_json::Value| { + send( + Method::PATCH, + format!("/api/v2/folders/{id}"), + token, + Some(body), + ) + }; + for parent in [&rock_id, &indie_id] { + assert_eq!( + patch( + &owner_token, + &rock_id, + serde_json::json!({ "parent_id": parent }) + ) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + } + // Nor under a folder of another library, though the caller owns both. + assert_eq!( + patch( + &owner_token, + &rock_id, + serde_json::json!({ "parent_id": foreign_folder }) + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + let mut chain = Vec::new(); + let mut parent: Option = None; + for depth in 1..=16 { + let folder = + json_body(create(&owner_token, &format!("Level {depth}"), parent.as_deref()).await) + .await; + let id = folder["id"].as_str().unwrap().to_owned(); + chain.push(id.clone()); + parent = Some(id); + } + assert_eq!( + create(&owner_token, "Seventeen", Some(&chain[15])) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + // Rock carries Indie: two high. Under depth 15 it would reach 17. + assert_eq!( + patch( + &owner_token, + &rock_id, + serde_json::json!({ "parent_id": chain[14] }) + ) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + let buried = patch( + &owner_token, + &rock_id, + serde_json::json!({ "parent_id": chain[13] }), + ) + .await; + assert_eq!(buried.status(), StatusCode::OK); + assert_eq!(json_body(buried).await["parent_id"], chain[13].as_str()); + // A rename alone leaves the folder where it is; `null` brings it home. + let renamed = json_body( + patch( + &owner_token, + &rock_id, + serde_json::json!({ "name": "Rock & Roll" }), + ) + .await, + ) + .await; + assert_eq!( + (renamed["name"].as_str(), renamed["parent_id"].as_str()), + (Some("Rock & Roll"), Some(chain[13].as_str())) + ); + let home = json_body( + patch( + &owner_token, + &rock_id, + serde_json::json!({ "parent_id": null }), + ) + .await, + ) + .await; + assert_eq!(home["parent_id"], serde_json::Value::Null); + assert_eq!(home["track_count"], 1); + // Back among the root's children, its name must not clash there either. + assert_eq!( + patch(&owner_token, &rock_id, serde_json::json!({ "name": "a/b" })) + .await + .status(), + StatusCode::CONFLICT + ); + assert_eq!( + patch( + &listener_token, + &rock_id, + serde_json::json!({ "name": "Mine" }) + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + + // A delete takes the sub-folders with it and unfiles what they held. + assert_eq!( + send( + Method::DELETE, + format!("/api/v2/folders/{rock_id}"), + &listener_token, + None + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + let removed = send( + Method::DELETE, + format!("/api/v2/folders/{rock_id}"), + &owner_token, + None, + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + assert_eq!(json_body(removed).await["unfiled"], 2); + let after = tree().await; + assert_eq!(after["unfiled_track_count"], 5); + assert!( + count_of(&after, "Indie").is_some(), + "the root's own Indie stays" + ); + assert_eq!( + after["folders"] + .as_array() + .unwrap() + .iter() + .filter(|folder| folder["name"] == "Indie") + .count(), + 1 + ); + assert_eq!( + send( + Method::DELETE, + format!("/api/v2/folders/{rock_id}"), + &owner_token, + None + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + + // The folder cap, filled in SQL to one short and then reached. + let held: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM library_folder WHERE library_id = ?") + .bind(library.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO library_folder \ + (id, library_id, parent_id, name, name_key, created_by, created_at, updated_at) \ + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ?) \ + SELECT lower(hex(randomblob(16))), ?, NULL, 'Bulk ' || i, 'bulk ' || i, ?, 0, 0 FROM n", + ) + .bind(4_999 - held) + .bind(library.to_string()) + .bind(owner.to_string()) + .execute(state.db.pool()) + .await + .unwrap(); + assert_eq!( + create(&owner_token, "The last", None).await.status(), + StatusCode::CREATED + ); + assert_eq!( + create(&owner_token, "One too many", None).await.status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + // Another library keeps its own count. + assert_eq!( + send( + Method::POST, + format!("/api/v2/libraries/{elsewhere}/folders"), + &owner_token, + Some(serde_json::json!({ "name": "Still room" })), + ) + .await + .status(), + StatusCode::CREATED + ); +} diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 93f06df3..4ff0b4b5 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -1422,6 +1422,7 @@ test("opens a track's properties from its context menu", async ({ page }) => { "Download", "Properties", "Edit tags", + "Move to folder…", ]); await expect(menu.getByRole("menuitem", { name: "Play" })).toBeFocused(); // Where focus is shows on its own, not only as a faint tint. @@ -5007,3 +5008,352 @@ test("offers a listener the folders without the upload", async ({ page }) => { ).toBeVisible(); await expect(page.getByRole("link", { name: "Upload music" })).toHaveCount(0); }); + +/** + * A stand-in for the filed-folder routes (RFC-012 decision 2), with state: the + * screen re-reads after every write, and the point is that it shows what the + * write did. Names collide case-insensitively, as on the server. + */ +async function serveFiledFolders(page: Page, songs: ReturnType[]) { + const state = { + folders: [] as Array<{ id: string; parent_id: string | null; name: string }>, + placed: new Map(), + writes: [] as Array<{ method: string; path: string; body: unknown }>, + }; + const tree = () => ({ + folders: state.folders.map((folder) => ({ + ...folder, + track_count: [...state.placed.values()].filter((id) => id === folder.id) + .length, + })), + unfiled_track_count: songs.filter((entry) => !state.placed.has(entry.id)) + .length, + }); + await page.route("**/api/v2/libraries/library-1/folders", async (route) => { + const request = route.request(); + if (request.method() === "POST") { + const body = request.postDataJSON() as { name: string; parent_id: string | null }; + state.writes.push({ method: "POST", path: "folders", body }); + const name = body.name.trim(); + if ( + state.folders.some( + (folder) => + folder.parent_id === body.parent_id && + folder.name.toLowerCase() === name.toLowerCase(), + ) + ) { + await route.fulfill({ status: 409, json: { error: "conflict" } }); + return; + } + const folder = { id: `folder-${state.folders.length + 1}`, parent_id: body.parent_id, name }; + state.folders.push(folder); + await route.fulfill({ status: 201, json: { ...folder, track_count: 0 } }); + return; + } + await route.fulfill({ json: tree() }); + }); + await page.route("**/api/v2/libraries/library-1/folders/*/tracks?*", (route) => { + const folder = new URL(route.request().url()).pathname.split("/").at(-2); + return route.fulfill({ + json: songs.filter((entry) => + folder === "unfiled" + ? !state.placed.has(entry.id) + : state.placed.get(entry.id) === folder, + ), + }); + }); + await page.route("**/api/v2/libraries/library-1/placements", async (route) => { + const body = route.request().postDataJSON() as { + track_ids: string[]; + folder_id: string | null; + }; + state.writes.push({ method: "PUT", path: "placements", body }); + for (const id of body.track_ids) { + if (body.folder_id) state.placed.set(id, body.folder_id); + else state.placed.delete(id); + } + await route.fulfill({ json: { placed: body.track_ids.length } }); + }); + await page.route("**/api/v2/folders/*", async (route) => { + const request = route.request(); + const id = new URL(request.url()).pathname.split("/").at(-1) ?? ""; + if (request.method() === "DELETE") { + state.writes.push({ method: "DELETE", path: id, body: null }); + const gone = new Set([id]); + for (const folder of state.folders) { + if (folder.parent_id && gone.has(folder.parent_id)) gone.add(folder.id); + } + let unfiled = 0; + for (const [track, folder] of [...state.placed]) { + if (gone.has(folder)) { + state.placed.delete(track); + unfiled += 1; + } + } + state.folders = state.folders.filter((folder) => !gone.has(folder.id)); + await route.fulfill({ json: { unfiled } }); + return; + } + const patch = request.postDataJSON() as { name?: string; parent_id?: string | null }; + state.writes.push({ method: "PATCH", path: id, body: patch }); + state.folders = state.folders.map((folder) => + folder.id === id + ? { + ...folder, + name: patch.name ?? folder.name, + parent_id: patch.parent_id === undefined ? folder.parent_id : patch.parent_id, + } + : folder, + ); + const folder = state.folders.find((entry) => entry.id === id); + await route.fulfill({ json: { ...folder, track_count: 0 } }); + }); + return state; +} + +/** + * Filing, end to end on the screen: a folder created, a track filed from its + * menu — the keyboard's way — another dragged onto a folder, the folder + * renamed and deleted, and its tracks back among the unfiled. No control + * here claims to touch a file, and none does: every write is to the folder + * routes. + */ +test("files tracks into folders from the menu and by dragging", async ({ + page, +}, testInfo) => { + const songs = [ + song(1, "Hidden Place", 0, false), + song(2, "Cocoon", 0, false), + song(3, "Pagan Poetry", 0, false), + ]; + const state = await serveFiledFolders(page, songs); + + await page.goto("/folders?view=filed"); + await expect(page.getByRole("heading", { name: "Filed" })).toBeVisible(); + await expect( + page.getByRole("navigation", { name: "Folder views" }).getByRole("link", { + name: "Filed", + }), + ).toHaveAttribute("aria-current", "page"); + const cards = page.getByRole("list", { name: "Folders" }); + await expect(cards.getByRole("link", { name: /Unfiled/ })).toContainText("3 tracks"); + + // A folder, and a second one refused for a name already taken. + await page.getByRole("button", { name: "New folder" }).click(); + await page.getByLabel("Folder name").fill("Rock"); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByRole("status")).toHaveText("“Rock” created."); + await page.getByRole("button", { name: "New folder" }).click(); + await page.getByLabel("Folder name").fill("ROCK"); + await page.getByRole("button", { name: "Create" }).click(); + await expect( + page.getByRole("alert").filter({ hasText: "A folder of that name is already here." }), + ).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + + // From the menu: the unfiled tracks, the first one filed into Rock. + await cards.getByRole("link", { name: /Unfiled/ }).click(); + await expect(page.getByRole("heading", { name: "Unfiled" })).toBeVisible(); + const rows = page.locator(".songs tbody tr"); + await expect(rows).toHaveCount(3); + await rows.first().locator("button.song-title").focus(); + await page.keyboard.press("Shift+F10"); + await page.getByRole("menuitem", { name: "Move to folder…" }).click(); + const picker = page.getByRole("dialog", { name: "Move “Hidden Place” to a folder" }); + await expect(picker).toBeVisible(); + await expectNoWcagViolations(page); + await picker.getByRole("button", { name: "Rock" }).click(); + await expect(picker).toBeHidden(); + await expect(rows).toHaveCount(2); + expect(state.writes.at(-1)).toEqual({ + method: "PUT", + path: "placements", + body: { track_ids: ["song-1"], folder_id: "folder-1" }, + }); + + // By dragging: a row onto the Rock card beside the unfiled tracks. A drag is + // a mouse gesture; the phone has the menu above, already proven. + if (testInfo.project.name === "desktop") { + await rows + .filter({ hasText: "Cocoon" }) + .dragTo(page.getByRole("list", { name: "File into" }).getByRole("link", { name: /Rock/ })); + await expect(page.getByRole("status")).toHaveText("1 track filed in Rock."); + await expect(rows).toHaveCount(1); + } else { + state.placed.set("song-2", "folder-1"); + } + + // Inside the folder: both, renamed, then deleted, and they come back unfiled. + await page.goto("/folders?view=filed&folder=folder-1"); + await expect(page.getByRole("heading", { name: "Rock" })).toBeVisible(); + await expect(rows).toHaveCount(2); + await page.getByRole("button", { name: "Rename" }).click(); + await page.getByLabel("New name").fill("Rock & Roll"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("heading", { name: "Rock & Roll" })).toBeVisible(); + page.once("dialog", (dialog) => void dialog.accept()); + await page.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByRole("heading", { name: "Filed" })).toBeVisible(); + await expect(page.getByRole("status")).toHaveText( + "Folder deleted: 2 tracks are unfiled.", + ); + await expect(cards.getByRole("link", { name: /Unfiled/ })).toContainText("3 tracks"); + await expectNoWcagViolations(page); +}); + +/** A listener walks the filed tree and is offered nothing that writes. */ +test("shows a listener the filed tree with nothing to change", async ({ page }) => { + libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }]; + const songs = [song(1, "Hidden Place", 0, false)]; + const state = await serveFiledFolders(page, songs); + state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" }); + + await page.goto("/folders?view=filed&folder=unfiled"); + await expect(page.locator(".songs tbody tr")).toHaveCount(1); + for (const name of ["New folder", "Rename", "Delete", "Move…"]) { + await expect(page.getByRole("button", { name })).toHaveCount(0); + } + // No destinations beside the unfiled tracks, and no drag on the rows. + await expect(page.getByRole("list", { name: "File into" })).toHaveCount(0); + await expect(page.locator(".songs tbody tr").first()).not.toHaveAttribute("draggable", "true"); + await page.locator(".songs tbody tr button.song-title").first().focus(); + await page.keyboard.press("Shift+F10"); + await expect(page.getByRole("menuitem", { name: "Properties" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Move to folder…" })).toHaveCount(0); +}); + +/** + * Received first, filed second (RFC-012 decision 7): an upload started from a + * folder commits as any other, then files the track it became. + */ +test("files a received track into the folder the upload started from", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; + const state = await serveFiledFolders(page, []); + state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" }); + + await page.goto("/upload?folder=folder-1"); + await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible(); + await page.getByLabel("Choose audio files").setInputFiles({ + name: "Army of Me.flac", + mimeType: "audio/flac", + buffer: Buffer.from(Array.from({ length: 20 }, (_, i) => i)), + }); + await expect(page.getByText("Added to the library")).toBeVisible(); + expect(uploadCommits).toEqual(["upload-1"]); + expect(state.writes).toEqual([ + { + method: "PUT", + path: "placements", + body: { track_ids: ["song-uploaded"], folder_id: "folder-1" }, + }, + ]); +}); + +/** + * Received is not undone by a filing that fails: the commit stands, and the + * line says the track arrived unfiled rather than claiming it was filed. + */ +test("keeps a received track when filing it fails, and says so", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; + const state = await serveFiledFolders(page, []); + state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" }); + await page.route("**/api/v2/libraries/library-1/placements", (route) => + route.fulfill({ status: 422, json: { error: "validation" } }), + ); + + await page.goto("/upload?folder=folder-1"); + await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible(); + await page.getByLabel("Choose audio files").setInputFiles({ + name: "Army of Me.flac", + mimeType: "audio/flac", + buffer: Buffer.from(Array.from({ length: 20 }, (_, i) => i)), + }); + await expect(page.getByText("Added to the library, but not filed")).toBeVisible(); + expect(uploadCommits).toEqual(["upload-1"]); +}); + +/** + * No file is taken before the folder is known, and a folder that is gone is + * said before anything is sent: either way a file would have arrived unfiled + * under a page that promised a folder. + */ +test("waits for the folder before taking files, and says when it is gone", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/api/v2/libraries/library-1/folders", async (route) => { + await held; + await route.fulfill({ json: { folders: [], unfiled_track_count: 0 } }); + }); + try { + await page.goto("/upload?folder=folder-gone"); + await expect(page.getByLabel("Choose audio files")).toBeDisabled(); + } finally { + release(); + } + await expect(page.getByLabel("Choose audio files")).toBeEnabled(); + await expect(page.getByRole("status")).toHaveText( + "The folder this upload was opened for cannot be found: received files will stay unfiled.", + ); +}); + +/** + * The root lists folders, not tracks. Coming back to it from the unfiled + * tracks must not leave those tracks behind in its controls. + */ +test("offers no tracks at the root after visiting the unfiled ones", async ({ + page, +}) => { + await serveFiledFolders(page, [song(1, "Hidden Place", 0, false)]); + await page.goto("/folders?view=filed&folder=unfiled"); + await expect(page.locator(".songs tbody tr")).toHaveCount(1); + const controls = page.locator(".page-controls"); + await expect(controls.getByRole("button", { name: /Play/ })).toBeVisible(); + await page + .getByRole("navigation", { name: "Folder path" }) + .getByRole("link", { name: "Filed", exact: true }) + .click(); + await expect(page.getByRole("heading", { name: "Filed" })).toBeVisible(); + await expect(controls.getByRole("button", { name: /Play/ })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Load more" })).toHaveCount(0); +}); + +/** + * A tree that could not be read says nothing about the folder: it is not + * "missing", and no file is taken until a reading works. + */ +test("takes no file while the folder cannot be checked", async ({ page }) => { + libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; + let answers = 0; + await page.route("**/api/v2/libraries/library-1/folders", (route) => { + answers += 1; + return answers === 1 + ? route.fulfill({ status: 500, json: { error: "boom" } }) + : route.fulfill({ + json: { + folders: [{ id: "folder-1", parent_id: null, name: "Rock", track_count: 0 }], + unfiled_track_count: 0, + }, + }); + }); + + await page.goto("/upload?folder=folder-1"); + const unchecked = page.getByRole("alert").filter({ + hasText: "could not be checked", + }); + await expect(unchecked).toBeVisible(); + await expect(page.getByLabel("Choose audio files")).toBeDisabled(); + await expect(page.getByText("cannot be found", { exact: false })).toHaveCount(0); + + await unchecked.getByRole("button", { name: "Check again" }).click(); + await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible(); + await expect(page.getByLabel("Choose audio files")).toBeEnabled(); +}); diff --git a/webapp/src/api/folders.test.ts b/webapp/src/api/folders.test.ts new file mode 100644 index 00000000..ea5b25e7 --- /dev/null +++ b/webapp/src/api/folders.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { + type FiledFolder, + folderOutline, + folderTrail, + isWithin, +} from "./folders"; + +const folder = (id: string, parent: string | null, name = id): FiledFolder => ({ + id, + parent_id: parent, + name, + track_count: 0, +}); + +describe("the filed tree, read on the client", () => { + const tree = [ + folder("rock", null, "Rock"), + folder("indie", "rock", "Indie"), + folder("lofi", "indie", "lo-fi"), + folder("ambient", null, "ambient"), + ]; + + it("walks from the root down to a folder", () => { + expect(folderTrail(tree, "lofi").map((entry) => entry.id)).toEqual([ + "rock", + "indie", + "lofi", + ]); + expect(folderTrail(tree, "gone")).toEqual([]); + }); + + it("stops on a cycle instead of looping", () => { + const looped = [folder("a", "b"), folder("b", "a")]; + expect(folderTrail(looped, "a").length).toBeLessThanOrEqual(3); + expect(folderOutline(looped)).toEqual([]); + }); + + it("outlines siblings by name, case aside, each before its children", () => { + expect( + folderOutline(tree).map(({ folder: entry, depth }) => [ + entry.name, + depth, + ]), + ).toEqual([ + ["ambient", 0], + ["Rock", 0], + ["Indie", 1], + ["lo-fi", 2], + ]); + }); + + it("knows what sits beneath a folder", () => { + expect(isWithin(tree, "lofi", "rock")).toBe(true); + expect(isWithin(tree, "rock", "rock")).toBe(true); + expect(isWithin(tree, "ambient", "rock")).toBe(false); + }); +}); diff --git a/webapp/src/api/folders.ts b/webapp/src/api/folders.ts new file mode 100644 index 00000000..6d753edb --- /dev/null +++ b/webapp/src/api/folders.ts @@ -0,0 +1,162 @@ +import type { Song } from "./catalog"; +import { call } from "./client"; + +/** + * Filed folders (RFC-012 decision 2): the library sorted in WaveFlow, never + * on disk. A track sits in at most one; one in none is unfiled. + */ +export type FiledFolder = { + id: string; + /** `null` at the root. */ + parent_id: string | null; + name: string; + /** The available tracks filed directly in it. */ + track_count: number; +}; + +export type FiledTree = { + folders: FiledFolder[]; + unfiled_track_count: number; +}; + +/** What the tracks route calls the root's unfiled entry. */ +export const UNFILED = "unfiled"; + +/** The most tracks one placement files: the server's cap. */ +export const MAX_PLACEMENTS = 500; + +/** How many tracks one page of a folder carries. */ +export const FOLDER_TRACKS_PAGE = 200; + +export const listFolders = (libraryId: string) => + call(`/api/v2/libraries/${libraryId}/folders`); + +export const createFolder = ( + libraryId: string, + name: string, + parentId: string | null, +) => + call(`/api/v2/libraries/${libraryId}/folders`, { + method: "POST", + body: JSON.stringify({ name, parent_id: parentId }), + }); + +/** + * A rename, a move, or both. `parent_id` left out stays where it is; `null` + * moves the folder to the root. + */ +export const updateFolder = ( + folderId: string, + patch: { name?: string; parent_id?: string | null }, +) => + call(`/api/v2/folders/${folderId}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); + +/** Deletes a folder and its sub-folders; their tracks become unfiled. */ +export const deleteFolder = (folderId: string) => + call<{ unfiled: number }>(`/api/v2/folders/${folderId}`, { + method: "DELETE", + }); + +/** One page of the tracks filed in a folder, or of the unfiled ones. */ +export const listFolderTracks = ( + libraryId: string, + folder: string, + offset: number, + limit = FOLDER_TRACKS_PAGE, + signal?: AbortSignal, +) => + call( + `/api/v2/libraries/${libraryId}/folders/${encodeURIComponent(folder)}/tracks?${new URLSearchParams( + { offset: String(offset), limit: String(limit) }, + )}`, + { signal }, + ); + +/** + * Files tracks into one folder, or unfiles them with `null`. All or nothing + * on the server; more than the cap is sent as several requests. + */ +export async function placeTracks( + libraryId: string, + trackIds: string[], + folderId: string | null, +): Promise { + let placed = 0; + for (let start = 0; start < trackIds.length; start += MAX_PLACEMENTS) { + const answer = await call<{ placed: number }>( + `/api/v2/libraries/${libraryId}/placements`, + { + method: "PUT", + body: JSON.stringify({ + track_ids: trackIds.slice(start, start + MAX_PLACEMENTS), + folder_id: folderId, + }), + }, + ); + placed += answer.placed; + } + return placed; +} + +/** + * The folders from the root down to `folderId`, itself included; empty when + * the folder is not in the tree. Walks parents, never more than the tree + * holds, so a corrupt answer cannot loop it. + */ +export function folderTrail( + folders: FiledFolder[], + folderId: string, +): FiledFolder[] { + const byId = new Map(folders.map((folder) => [folder.id, folder])); + const trail: FiledFolder[] = []; + let at = byId.get(folderId); + while (at && trail.length <= folders.length) { + trail.unshift(at); + at = at.parent_id ? byId.get(at.parent_id) : undefined; + } + return trail; +} + +/** + * The tree flattened in display order — siblings by name, each followed by + * its own sub-folders — with each folder's depth, the root's children at 0. + */ +export function folderOutline( + folders: FiledFolder[], +): Array<{ folder: FiledFolder; depth: number }> { + const children = new Map(); + for (const folder of folders) { + const siblings = children.get(folder.parent_id) ?? []; + siblings.push(folder); + children.set(folder.parent_id, siblings); + } + const outline: Array<{ folder: FiledFolder; depth: number }> = []; + const seen = new Set(); + const walk = (parent: string | null, depth: number) => { + const siblings = [...(children.get(parent) ?? [])].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + for (const folder of siblings) { + if (seen.has(folder.id)) continue; + seen.add(folder.id); + outline.push({ folder, depth }); + walk(folder.id, depth + 1); + } + }; + walk(null, 0); + return outline; +} + +/** Whether `candidate` is `folderId` or sits anywhere beneath it. */ +export function isWithin( + folders: FiledFolder[], + candidate: string, + folderId: string, +): boolean { + return folderTrail(folders, candidate).some( + (folder) => folder.id === folderId, + ); +} diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index c28c695c..5caf7e53 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -67,6 +67,21 @@ export { setFavorite, setRating, } from "./favorites"; +export type { FiledFolder, FiledTree } from "./folders"; +export { + createFolder, + deleteFolder, + FOLDER_TRACKS_PAGE, + folderOutline, + folderTrail, + isWithin, + listFolders, + listFolderTracks, + MAX_PLACEMENTS, + placeTracks, + UNFILED, + updateFolder, +} from "./folders"; export type { DiskFolder, DiskListing, diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index 0cad75a1..0fdcb592 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -6,6 +6,7 @@ import { artistTopSongs, canvasUrl, DISK_TREE_PAGE, + FOLDER_TRACKS_PAGE, getAlbum, getArtist, getDiskTree, @@ -20,6 +21,8 @@ import { listArtists, listBookmarks, listFavorites, + listFolders, + listFolderTracks, listGenreSongs, listGenres, listHistory, @@ -227,6 +230,38 @@ export const diskTreeQuery = (libraryId: string, path: string) => : undefined, }); +/** + * A library's whole filed tree: bounded by the server's folder cap, so it + * is held whole rather than paged. + */ +export const filedTreeQuery = (libraryId: string) => + queryOptions({ + queryKey: ["filed-tree", libraryId], + queryFn: () => listFolders(libraryId), + }); + +/** + * The tracks filed in one folder, or the unfiled ones, a page at a time. A + * short page is the end. + */ +export const folderTracksQuery = (libraryId: string, folder: string) => + infiniteQueryOptions({ + queryKey: ["folder-tracks", libraryId, folder], + queryFn: ({ pageParam, signal }) => + listFolderTracks( + libraryId, + folder, + pageParam, + FOLDER_TRACKS_PAGE, + signal, + ), + initialPageParam: 0, + getNextPageParam: (last, _pages, lastParam) => + last.length < FOLDER_TRACKS_PAGE + ? undefined + : lastParam + FOLDER_TRACKS_PAGE, + }); + export const lyricsQuery = (trackId: string) => queryOptions({ queryKey: ["lyrics", trackId], diff --git a/webapp/src/components/filed-folders.tsx b/webapp/src/components/filed-folders.tsx new file mode 100644 index 00000000..43301696 --- /dev/null +++ b/webapp/src/components/filed-folders.tsx @@ -0,0 +1,482 @@ +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { + type DragEvent, + type FormEvent, + type ReactNode, + useMemo, + useState, +} from "react"; + +import { + ApiError, + createFolder, + deleteFolder, + type FiledFolder, + folderTrail, + isWithin, + placeTracks, + UNFILED, + updateFolder, +} from "../api"; +import { filedTreeQuery, folderTracksQuery } from "../api/queries"; +import { usePlayer } from "../player/player"; +import { useI18n } from "../state/i18n"; +import { + mayCorrectTracks, + mayUploadTo, + useLibraryScope, +} from "../state/library-scope"; +import { FolderPicker } from "./folder-picker"; +import { Icon } from "./icons"; +import { Loading, PageHeader } from "./page-shell"; +import { SongTable, TRACK_DRAG_TYPE } from "./song-table"; + +/** What a dragged folder card carries: its id. */ +const FOLDER_DRAG_TYPE = "application/x-waveflow-folder"; + +type Notice = { tone: "status" | "error"; text: string } | null; + +/** + * The library as its owner and managers sort it (RFC-012 decision 2): folders + * that exist only in WaveFlow, a track in at most one of them. + * + * `folder` is empty for the root, `unfiled` for the tracks filed nowhere, and + * otherwise a folder id. Any member walks it; owners and managers also create, + * rename, move and delete folders, and file tracks — from the track menu, by + * dragging a row onto a folder, or by dragging a folder onto another. Every + * drag has a keyboard twin: the menu's "Move to folder…" and the folder's own + * "Move…". + */ +export function FiledFolders({ + folder, + switcher, +}: { + folder: string; + switcher: ReactNode; +}) { + const { t } = useI18n(); + const player = usePlayer(); + const navigate = useNavigate(); + const client = useQueryClient(); + const { active, ready } = useLibraryScope(); + const libraryId = active?.id ?? ""; + const may = mayCorrectTracks(active ?? undefined); + const tree = useQuery({ + ...filedTreeQuery(libraryId), + enabled: active !== null, + }); + const listsTracks = folder !== ""; + const tracks = useInfiniteQuery({ + ...folderTracksQuery(libraryId, folder || UNFILED), + enabled: active !== null && listsTracks, + }); + // A disabled query still answers from its cache: at the root, the unfiled + // tracks read on the way in would otherwise be offered to play and to page. + const songs = useMemo( + () => (listsTracks ? (tracks.data?.pages.flat() ?? []) : []), + [listsTracks, tracks.data], + ); + const morePages = listsTracks && tracks.hasNextPage; + const [notice, setNotice] = useState(null); + const [form, setForm] = useState<"create" | "rename" | null>(null); + const [moving, setMoving] = useState(false); + const [dropTarget, setDropTarget] = useState(null); + + const folders = tree.data?.folders ?? []; + const isReal = folder !== "" && folder !== UNFILED; + const trail = isReal ? folderTrail(folders, folder) : []; + const here = trail.at(-1); + const missing = isReal && tree.data !== undefined && !here; + // A folder's sub-folders; at the root and beside the unfiled tracks, the + // root's own folders — there, the places an unfiled track can be dropped. + const children = folders.filter( + (entry) => entry.parent_id === (isReal ? folder : null), + ); + const title = + folder === UNFILED ? t("filed.unfiled") : (here?.name ?? t("filed.title")); + + async function refresh() { + await Promise.all([ + client.invalidateQueries({ queryKey: ["filed-tree", libraryId] }), + client.invalidateQueries({ queryKey: ["folder-tracks", libraryId] }), + ]); + } + + /** Runs a write, says how it went, and re-reads what it changed. */ + async function attempt( + write: () => Promise, + refused?: (error: ApiError) => string | null, + ) { + setNotice(null); + try { + const said = await write(); + await refresh(); + setNotice({ tone: "status", text: said }); + return true; + } catch (error) { + const text = + (error instanceof ApiError && refused?.(error)) || t("filed.failed"); + setNotice({ tone: "error", text }); + return false; + } + } + + const named = (error: ApiError) => + error.status === 409 + ? t("filed.nameTaken") + : error.status === 422 + ? t("filed.refused") + : null; + + function fileInto(target: FiledFolder | null, trackIds: string[]) { + void attempt(async () => { + const count = await placeTracks(libraryId, trackIds, target?.id ?? null); + return t("filed.filed", { + count, + folder: target?.name ?? t("filed.unfiled"), + }); + }); + } + + function moveFolder(moved: string, target: string | null) { + const name = folders.find((entry) => entry.id === moved)?.name ?? ""; + void attempt(async () => { + await updateFolder(moved, { parent_id: target }); + return t("filed.folderMoved", { name }); + }, named); + } + + // A drop target accepts a row, or a folder that is not itself or above it. + const dropProps = (target: FiledFolder | null) => { + if (!may) return {}; + const key = target?.id ?? UNFILED; + const accepts = (event: DragEvent) => + event.dataTransfer.types.includes(TRACK_DRAG_TYPE) || + (target !== null && event.dataTransfer.types.includes(FOLDER_DRAG_TYPE)); + return { + onDragOver: (event: DragEvent) => { + if (!accepts(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setDropTarget(key); + }, + onDragLeave: () => + setDropTarget((current) => (current === key ? null : current)), + onDrop: (event: DragEvent) => { + setDropTarget(null); + const rows = event.dataTransfer.getData(TRACK_DRAG_TYPE); + const dragged = event.dataTransfer.getData(FOLDER_DRAG_TYPE); + if (rows) { + event.preventDefault(); + fileInto(target, JSON.parse(rows) as string[]); + } else if ( + dragged && + target && + !isWithin(folders, target.id, dragged) + ) { + event.preventDefault(); + moveFolder(dragged, target.id); + } + }, + }; + }; + + async function submitName(event: FormEvent) { + event.preventDefault(); + const name = String(new FormData(event.currentTarget).get("name") ?? ""); + const done = await attempt(async () => { + if (form === "rename" && here) { + const renamed = await updateFolder(here.id, { name }); + return t("filed.renamed", { name: renamed.name }); + } + const created = await createFolder( + libraryId, + name, + isReal ? folder : null, + ); + return t("filed.created", { name: created.name }); + }, named); + if (done) setForm(null); + } + + async function remove() { + if (!here || !window.confirm(t("filed.confirmDelete", { name: here.name }))) + return; + const parent = here.parent_id; + const done = await attempt(async () => { + const { unfiled } = await deleteFolder(here.id); + return t("filed.deleted", { count: unfiled }); + }); + if (done) { + await navigate({ + to: "/folders", + search: { view: "filed", folder: parent ?? "" }, + }); + } + } + + const link = (target: string) => ({ + to: "/folders" as const, + search: { view: "filed" as const, folder: target }, + }); + + const controls = ( + <> + {songs.length ? ( + + ) : null} + {may && folder !== UNFILED && !missing ? ( + + ) : null} + {may && here ? ( + <> + + + + + ) : null} + {here && mayUploadTo(active) ? ( + + + {t("filed.uploadHere")} + + ) : null} + + ); + + const header = ( + + {controls} + + ); + + const crumbs = ( + + ); + + if (ready && !active) { + return ( +
+ {header} + {switcher} +

{t("folders.noLibrary")}

+
+ ); + } + + const card = ( + target: FiledFolder | null, + label: string, + count: number, + to: string, + ) => ( +
  • + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(FOLDER_DRAG_TYPE, target.id); + event.dataTransfer.setData("text/plain", target.name); + } + : undefined + } + {...dropProps(target)} + > + + {label} + {t("common.tracks", { count })} + +
  • + ); + + return ( +
    + {header} + {switcher} + {crumbs} +

    {t("filed.note")}

    + {notice ? ( +

    + {notice.text} +

    + ) : null} + {form ? ( +
    void submitName(event)} + > + + + +
    + ) : null} + {missing ? ( +

    {t("filed.missing")}

    + ) : !tree.data ? ( + + ) : ( + <> + {(folder !== UNFILED || may) && (children.length || folder === "") ? ( +
      + {children.map((entry) => + card(entry, entry.name, entry.track_count, entry.id), + )} + {folder === "" + ? card( + null, + t("filed.unfiled"), + tree.data.unfiled_track_count, + UNFILED, + ) + : null} +
    + ) : null} + {listsTracks ? ( + !tracks.data ? ( + + ) : songs.length ? ( + + ) : !children.length || folder === UNFILED ? ( +

    + {t(folder === UNFILED ? "filed.allFiled" : "filed.empty")} +

    + ) : null + ) : null} + {morePages ? ( +
    + + {tracks.isFetchNextPageError ? ( +

    + {t("songs.loadMoreError")} +

    + ) : null} +
    + ) : null} + + )} + {moving && here ? ( + { + await updateFolder(here.id, { parent_id: target }); + await refresh(); + setNotice({ + tone: "status", + text: t("filed.folderMoved", { name: here.name }), + }); + }} + onClose={() => setMoving(false)} + /> + ) : null} +
    + ); +} diff --git a/webapp/src/components/folder-picker.tsx b/webapp/src/components/folder-picker.tsx new file mode 100644 index 00000000..29389777 --- /dev/null +++ b/webapp/src/components/folder-picker.tsx @@ -0,0 +1,142 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useId, useRef, useState } from "react"; + +import { folderOutline, isWithin } from "../api"; +import { filedTreeQuery } from "../api/queries"; +import { useI18n } from "../state/i18n"; +import { Icon } from "./icons"; + +/** + * Choose a filed folder (RFC-012 decision 2): the keyboard's way to file a + * track or move a folder, where the folders page also takes a drag. + * + * A native `` opened with `showModal`, as Properties is: the focus + * trap, Escape and the inert page behind come from the platform. The tree is + * one list, indented by depth; the first entry is the root, which means + * **Unfiled** for a track and **the top level** for a folder — the caller says + * which. `exclude` leaves out a folder and everything beneath it: a folder + * cannot be moved under itself, and the server would refuse it anyway. + */ +export function FolderPicker({ + libraryId, + title, + rootLabel, + current, + exclude, + onPick, + onClose, +}: { + libraryId: string; + title: string; + rootLabel: string; + /** + * Where the thing is now, marked and not offered as a destination: a folder + * id, `null` for the root, or `undefined` when it is not known. + */ + current?: string | null; + exclude?: string; + /** Rejects to keep the dialog open and say the move failed. */ + onPick: (folderId: string | null) => Promise; + onClose: () => void; +}) { + const { t } = useI18n(); + const dialog = useRef(null); + const titleId = useId(); + const { data, error } = useQuery(filedTreeQuery(libraryId)); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const node = dialog.current; + if (node && !node.open) node.showModal(); + }, []); + + const folders = data?.folders ?? []; + const outline = folderOutline(folders).filter( + ({ folder }) => !exclude || !isWithin(folders, folder.id, exclude), + ); + + async function pick(folderId: string | null) { + setBusy(true); + setFailed(false); + try { + await onPick(folderId); + dialog.current?.close(); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + } + + const entry = (folderId: string | null, label: string, depth: number) => { + const here = current !== undefined && current === folderId; + return ( +
  • + +
  • + ); + }; + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes a natively + { + // A click on the backdrop lands on the dialog itself, outside its box. + const node = dialog.current; + if (!node || event.target !== node) return; + const box = node.getBoundingClientRect(); + const inside = + event.clientX >= box.left && + event.clientX <= box.right && + event.clientY >= box.top && + event.clientY <= box.bottom; + if (!inside) node.close(); + }} + > +
    +

    {title}

    + +
    + {error ? ( +

    + {t("filed.loadError")} +

    + ) : ( +
      + {entry(null, rootLabel, 0)} + {outline.map(({ folder, depth }) => + entry(folder.id, folder.name, depth + 1), + )} +
    + )} + {failed ? ( +

    + {t("filed.moveFailed")} +

    + ) : null} +
    + ); +} diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index e4971f79..fe03e551 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -12,6 +12,12 @@ import { useTrackMenu } from "./track-menu"; const RATING_STARS = [1, 2, 3, 4, 5]; +/** + * What a dragged row carries: the track ids, as JSON. A type of its own, so a + * folder takes a drop from a song row and nothing else. + */ +export const TRACK_DRAG_TYPE = "application/x-waveflow-tracks"; + /** * Five stars, where clicking the star already set clears the rating — the * server spells that `rating: 0`, and without it a rating could be changed but @@ -63,6 +69,7 @@ export function SongTable({ numbering = "position", variant = "default", captions, + draggableRows = false, }: { songs: Song[]; numbering?: "track" | "position"; @@ -72,6 +79,11 @@ export function SongTable({ * name, where the screen is about files. */ captions?: string[]; + /** + * Rows may be dragged onto a filed folder. Only where filing is offered: + * a row that drags anywhere else is a gesture that does nothing. + */ + draggableRows?: boolean; }) { const player = usePlayer(); const { t } = useI18n(); @@ -170,6 +182,19 @@ export function SongTable({ key={rowIds[position]} className={active ? "active" : undefined} {...rowProps(songs, position)} + draggable={draggableRows || undefined} + onDragStart={ + draggableRows + ? (event) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + TRACK_DRAG_TYPE, + JSON.stringify([song.id]), + ); + event.dataTransfer.setData("text/plain", song.title); + } + : undefined + } > {numbering === "track" diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/track-menu.tsx index 09b822f9..ae0fe3ed 100644 --- a/webapp/src/components/track-menu.tsx +++ b/webapp/src/components/track-menu.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { type KeyboardEvent, @@ -10,11 +11,12 @@ import { } from "react"; import { createPortal } from "react-dom"; -import { type Song, streamUrl } from "../api"; +import { placeTracks, type Song, streamUrl } from "../api"; import { downloadUrl, saveFrom } from "../lib/download"; import { usePlayer } from "../player/player"; import { useI18n } from "../state/i18n"; import { mayCorrectTracks, useLibraryScope } from "../state/library-scope"; +import { FolderPicker } from "./folder-picker"; import { Icon } from "./icons"; import { TrackProperties } from "./track-properties"; @@ -53,6 +55,13 @@ export function useTrackMenu() { song: Song; returnTo: HTMLElement | null; } | null>(null); + // Filing, like Properties, replaces the menu with a dialog of its own. + const [moving, setMoving] = useState<{ + song: Song; + returnTo: HTMLElement | null; + } | null>(null); + const client = useQueryClient(); + const { t } = useI18n(); // A long press that opened the menu is followed, as the finger lifts, by the // click the browser synthesises for the tap it thinks it saw; on a row whose // title plays the track, that click would start playback under the menu. @@ -192,6 +201,10 @@ export function useTrackMenu() { setProperties({ song, returnTo: opened.returnTo }); setOpened(null); }} + onMove={(song) => { + setMoving({ song, returnTo: opened.returnTo }); + setOpened(null); + }} />, document.body, ) @@ -206,6 +219,29 @@ export function useTrackMenu() { }} /> ) : null} + {moving ? ( + { + await placeTracks( + moving.song.library_id, + [moving.song.id], + folderId, + ); + await Promise.all([ + client.invalidateQueries({ queryKey: ["filed-tree"] }), + client.invalidateQueries({ queryKey: ["folder-tracks"] }), + ]); + }} + onClose={() => { + const returnTo = moving.returnTo; + setMoving(null); + returnTo?.focus(); + }} + /> + ) : null} {downloadFailed ? createPortal( setDownloadFailed(false)} />, @@ -240,11 +276,13 @@ function TrackMenu({ opened, onClose, onProperties, + onMove, onDownloadFailed, }: { opened: Opened; onClose: () => void; onProperties: (song: Song) => void; + onMove: (song: Song) => void; onDownloadFailed: () => void; }) { const { t } = useI18n(); @@ -380,6 +418,13 @@ function TrackMenu({ params: { trackId: song.id }, }), }, + // Filing is gated as a correction is (RFC-012 decision 3). + { + key: "move", + icon: "folder" as const, + label: t("trackMenu.move"), + run: () => onMove(song), + }, ] : []), ]; @@ -436,9 +481,9 @@ function TrackMenu({ tabIndex={-1} onClick={() => { item.run(); - // Properties replaces the menu with its own dialog, which takes - // focus; every other entry hands it back to the row. - if (item.key !== "properties") onClose(); + // Properties and filing replace the menu with a dialog of their + // own, which takes focus; every other entry hands it back to the row. + if (item.key !== "properties" && item.key !== "move") onClose(); }} > diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 5485c1b2..0991be6f 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -862,19 +862,38 @@ const adminRoute = createRoute({ const foldersRoute = createRoute({ getParentRoute: () => authedRoute, path: "/folders", - validateSearch: (search: Record): { path: string } => ({ - path: typeof search.path === "string" ? search.path : "", + // Every key optional, so a link names only what it changes: the rail links + // to the disk root, a crumb names its path, a filed card its folder. + validateSearch: ( + search: Record, + ): { view?: "disk" | "filed"; path?: string; folder?: string } => ({ + view: search.view === "filed" ? "filed" : undefined, + path: typeof search.path === "string" ? search.path : undefined, + folder: typeof search.folder === "string" ? search.folder : undefined, }), component: function FoldersRoute() { - const { path } = foldersRoute.useSearch(); - return ; + const { view, path, folder } = foldersRoute.useSearch(); + return ( + + ); }, }); const uploadRoute = createRoute({ getParentRoute: () => authedRoute, path: "/upload", - component: UploadPage, + // A filed folder to put what is received into (RFC-012 decision 7). + validateSearch: (search: Record): { folder?: string } => ({ + folder: typeof search.folder === "string" ? search.folder : undefined, + }), + component: function UploadRoute() { + const { folder } = uploadRoute.useSearch(); + return ; + }, }); const trackEditRoute = createRoute({ diff --git a/webapp/src/pages/folders-page.tsx b/webapp/src/pages/folders-page.tsx index 1e38f1e0..2018421c 100644 --- a/webapp/src/pages/folders-page.tsx +++ b/webapp/src/pages/folders-page.tsx @@ -1,9 +1,10 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useMemo } from "react"; +import { type ReactNode, useMemo } from "react"; import { type DiskFolder, RECEIVED_FOLDER } from "../api"; import { diskTreeQuery } from "../api/queries"; +import { FiledFolders } from "../components/filed-folders"; import { Icon } from "../components/icons"; import { Loading, PageHeader } from "../components/page-shell"; import { SongTable } from "../components/song-table"; @@ -33,7 +34,13 @@ function crumbsOf(path: string): Array<{ name: string; path: string }> { * The folder is in the address (`?path=`), so back and forward walk the tree * and a folder can be linked to. */ -export function FoldersPage({ path }: { path: string }) { +function DiskFolders({ + path, + switcher, +}: { + path: string; + switcher: ReactNode; +}) { const { t } = useI18n(); const player = usePlayer(); const { active, ready } = useLibraryScope(); @@ -137,6 +144,7 @@ export function FoldersPage({ path }: { path: string }) { return (
    {header} + {switcher}

    {t("folders.noLibrary")}

    ); @@ -146,6 +154,7 @@ export function FoldersPage({ path }: { path: string }) { return (
    {header} + {switcher} {breadcrumbs}
    @@ -155,6 +164,7 @@ export function FoldersPage({ path }: { path: string }) { return (
    {header} + {switcher} {breadcrumbs}

    {t("folders.readOnly")}

    {folders.length ? ( @@ -199,3 +209,45 @@ export function FoldersPage({ path }: { path: string }) {
    ); } + +/** + * The two trees of a library, side by side and never mixed (RFC-012 decision + * 1): **On disk**, read-only, and **Filed**, sorted in WaveFlow. The view is in + * the address with the place in it, so back and forward walk both. + */ +export function FoldersPage({ + view, + path, + folder, +}: { + view: "disk" | "filed"; + path: string; + folder: string; +}) { + const { t } = useI18n(); + const switcher = ( + + ); + return view === "filed" ? ( + + ) : ( + + ); +} diff --git a/webapp/src/pages/upload-page.tsx b/webapp/src/pages/upload-page.tsx index 4ea42793..a32c530f 100644 --- a/webapp/src/pages/upload-page.tsx +++ b/webapp/src/pages/upload-page.tsx @@ -1,3 +1,4 @@ +import { useQuery } from "@tanstack/react-query"; import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { @@ -5,10 +6,12 @@ import { commitUpload, getUploadSession, negotiateUploads, + placeTracks, putUploadChunk, type UploadDecision, type UploadSessionState, } from "../api"; +import { filedTreeQuery } from "../api/queries"; import { PageHeader } from "../components/page-shell"; import type { HashRequest, HashResponse } from "../lib/hash-worker"; import { @@ -40,6 +43,13 @@ type Item = { * picker happens to point when its turn comes. */ libraryId: string; + /** + * The filed folder to put the track in once received, captured like the + * library: received first and filed second (RFC-012 decision 7). + */ + folderId?: string; + /** Received, but the filing after it failed: the track is unfiled. */ + unfiled?: boolean; status: Status; /** Of the current step, from 0 to 1. */ progress: number; @@ -204,9 +214,30 @@ function failureReason(cause: unknown): TranslationKey { * session it left open instead of starting over. One at a time is also what the * per-account session limit expects. */ -export function UploadPage() { +export function UploadPage({ folder }: { folder?: string } = {}) { const { t } = useI18n(); const { active } = useLibraryScope(); + // The folder is named only if it is one of the active library's: a link + // kept across a change of library must not file into the other one. + const { + data: tree, + isPending: treePending, + refetch: checkFolder, + } = useQuery({ + ...filedTreeQuery(active?.id ?? ""), + enabled: active !== null && folder !== undefined, + }); + const into = folder + ? tree?.folders.find((entry) => entry.id === folder) + : undefined; + // Files are not taken until the folder is known: one picked before would + // be captured with no folder and arrive unfiled, with nothing to say so. + // A tree that could not be read is not known either — it says nothing about + // whether the folder exists — so the picker waits for a reading that works. + const awaitingFolder = folder !== undefined && tree === undefined; + const folderUnchecked = awaitingFolder && !treePending; + // A folder the tree was read without is said before anything is sent. + const folderMissing = folder !== undefined && tree !== undefined && !into; const [items, setItems] = useState([]); const pending = useRef([]); const running = useRef(false); @@ -229,7 +260,7 @@ export function UploadPage() { } async function transfer(item: Item) { - const { key, file, libraryId } = item; + const { key, file, libraryId, folderId } = item; const fraction = (bytes: number) => file.size === 0 ? 1 : bytes / file.size; if (!isUploadable(file.name)) { @@ -280,8 +311,18 @@ export function UploadPage() { () => cancelled.current, ); update(key, { status: "committing", progress: 1 }); - await commitUpload(session.session_id); - update(key, { status: "done", progress: 1 }); + const committed = await commitUpload(session.session_id); + // The file is in. Filing it is a second, separate write: if it fails, + // the track stays received and unfiled, and the line says so. + let unfiled = false; + if (folderId) { + try { + await placeTracks(libraryId, [committed.track_id], folderId); + } catch { + unfiled = true; + } + } + update(key, { status: "done", progress: 1, unfiled }); } catch (cause) { if (cause instanceof CancelledUpload) return; update(key, { status: "failed", reason: failureReason(cause) }); @@ -315,6 +356,7 @@ export function UploadPage() { key: keys.current++, file, libraryId: active.id, + folderId: into?.id, status: "waiting", progress: 0, }), @@ -330,6 +372,7 @@ export function UploadPage() { reason: t(item.reason ?? "upload.reason.error"), }); } + if (item.status === "done" && item.unfiled) return t("upload.doneUnfiled"); return t(STATUS_TEXT[item.status]); } @@ -367,9 +410,33 @@ export function UploadPage() { title={t("upload.title", { library: active.name })} />

    {t("upload.detail")}

    + {into ? ( +

    + {t("upload.into", { folder: into.name })} +

    + ) : null} + {folderUnchecked ? ( +
    + {t("upload.folderUnchecked")}{" "} + +
    + ) : null} + {folderMissing ? ( +

    + {t("upload.folderMissing")} +

    + ) : null}

    {t("upload.formats", { extensions: UPLOAD_EXTENSIONS.join(", ") })} diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 214704e4..5578d7d7 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -451,6 +451,59 @@ const en = { "folders.upload": "Upload music", "folders.empty": "No indexed track lives here.", "folders.noLibrary": "No library is available to this account.", + "folders.views": "Folder views", + "folders.viewDisk": "On disk", + "folders.viewFiled": "Filed", + "filed.title": "Filed", + "filed.unfiled": "Unfiled", + "filed.topLevel": "Top level", + "filed.here": "Here now", + "filed.destinations": "Folders to choose from", + "filed.loadError": "The folders could not be loaded.", + "filed.moveFailed": "The move failed. Try again.", + "filed.moveTrack": "Move “{title}” to a folder", + "filed.moveFolder": "Move “{name}”", + "filed.note": + "Folders that exist only in WaveFlow. Filing a track here moves no file.", + "filed.newFolder": "New folder", + "filed.name": "Folder name", + "filed.renameLabel": "New name", + "filed.create": "Create", + "filed.save": "Save", + "filed.cancel": "Cancel", + "filed.rename": "Rename", + "filed.move": "Move…", + "filed.delete": "Delete", + "filed.confirmDelete": + "Delete “{name}” and its sub-folders? Their tracks become unfiled; no file is touched.", + "filed.created": "“{name}” created.", + "filed.renamed": "Renamed to “{name}”.", + "filed.deleted": { + one: "Folder deleted: {count} track is unfiled.", + other: "Folder deleted: {count} tracks are unfiled.", + }, + "filed.filed": { + one: "{count} track filed in {folder}.", + other: "{count} tracks filed in {folder}.", + }, + "filed.folderMoved": "“{name}” moved.", + "filed.nameTaken": "A folder of that name is already here.", + "filed.refused": + "That folder cannot go there: check its name, or the tree is too deep or full.", + "filed.failed": "That did not work. Try again.", + "filed.missing": "This folder no longer exists.", + "filed.empty": "Nothing is filed here yet.", + "filed.allFiled": "Every track is filed.", + "filed.uploadHere": "Upload here", + "filed.fileInto": "File into", + "trackMenu.move": "Move to folder…", + "upload.into": "Received files go into the folder {folder}.", + "upload.doneUnfiled": "Added to the library, but not filed", + "upload.folderMissing": + "The folder this upload was opened for cannot be found: received files will stay unfiled.", + "upload.folderUnchecked": + "The folder this upload was opened for could not be checked, so no file is taken yet.", + "upload.checkAgain": "Check again", "nav.group.library": "Library", "nav.group.listen": "Listening", "nav.group.account": "Account", @@ -1091,6 +1144,59 @@ const fr: Record = { "folders.upload": "Envoyer de la musique", "folders.empty": "Aucune piste indexée ici.", "folders.noLibrary": "Aucune bibliothèque n’est accessible à ce compte.", + "folders.views": "Vues des dossiers", + "folders.viewDisk": "Sur le disque", + "folders.viewFiled": "Classés", + "filed.title": "Classés", + "filed.unfiled": "Non classés", + "filed.topLevel": "Premier niveau", + "filed.here": "Ici actuellement", + "filed.destinations": "Dossiers au choix", + "filed.loadError": "Les dossiers n’ont pas pu être chargés.", + "filed.moveFailed": "Le déplacement a échoué. Réessayez.", + "filed.moveTrack": "Déplacer « {title} » vers un dossier", + "filed.moveFolder": "Déplacer « {name} »", + "filed.note": + "Des dossiers qui n’existent que dans WaveFlow. Classer une piste ici ne déplace aucun fichier.", + "filed.newFolder": "Nouveau dossier", + "filed.name": "Nom du dossier", + "filed.renameLabel": "Nouveau nom", + "filed.create": "Créer", + "filed.save": "Enregistrer", + "filed.cancel": "Annuler", + "filed.rename": "Renommer", + "filed.move": "Déplacer…", + "filed.delete": "Supprimer", + "filed.confirmDelete": + "Supprimer « {name} » et ses sous-dossiers ? Leurs pistes redeviennent non classées ; aucun fichier n’est touché.", + "filed.created": "« {name} » créé.", + "filed.renamed": "Renommé en « {name} ».", + "filed.deleted": { + one: "Dossier supprimé : {count} piste n’est plus classée.", + other: "Dossier supprimé : {count} pistes ne sont plus classées.", + }, + "filed.filed": { + one: "{count} piste classée dans {folder}.", + other: "{count} pistes classées dans {folder}.", + }, + "filed.folderMoved": "« {name} » déplacé.", + "filed.nameTaken": "Un dossier de ce nom existe déjà ici.", + "filed.refused": + "Ce dossier ne peut pas aller là : vérifiez son nom, ou l’arborescence est trop profonde ou pleine.", + "filed.failed": "Cela n’a pas fonctionné. Réessayez.", + "filed.missing": "Ce dossier n’existe plus.", + "filed.empty": "Rien n’est encore classé ici.", + "filed.allFiled": "Toutes les pistes sont classées.", + "filed.uploadHere": "Envoyer ici", + "filed.fileInto": "Classer dans", + "trackMenu.move": "Déplacer vers un dossier…", + "upload.into": "Les fichiers reçus vont dans le dossier {folder}.", + "upload.doneUnfiled": "Ajouté à la bibliothèque, mais pas classé", + "upload.folderMissing": + "Le dossier pour lequel cet envoi a été ouvert est introuvable : les fichiers reçus resteront non classés.", + "upload.folderUnchecked": + "Le dossier pour lequel cet envoi a été ouvert n’a pas pu être vérifié : aucun fichier n’est pris pour l’instant.", + "upload.checkAgain": "Vérifier à nouveau", "nav.group.library": "Bibliothèque", "nav.group.listen": "Écoute", "nav.group.account": "Compte", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index 8b3af5e4..49de1ed8 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -2949,3 +2949,89 @@ button.danger { .folder-upload:hover { border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); } + +/* The filed tree (RFC-012 decision 2). A folder a drag hovers over says it + will take the drop. */ +.folder-views { + margin: 0 0 var(--space-3); +} + +.folder-row.drop-target { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, var(--panel-soft)); +} + +.folder-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-2); + margin: 0 0 var(--space-4); +} + +.folder-form-field { + display: grid; + flex: 1 1 14rem; + gap: var(--space-1); +} + +.folder-notice { + margin: 0 0 var(--space-3); +} + +.folder-picker[open] { + display: grid; + gap: var(--space-4); + width: min(28rem, calc(100vw - 2rem)); + max-height: min(36rem, calc(100dvh - 2rem)); + padding: var(--space-5); + overflow-y: auto; + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +.folder-picker::backdrop { + background: color-mix(in srgb, black 55%, transparent); +} + +.folder-picker header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); +} + +.folder-picker h2 { + margin: 0; + font-size: var(--text-lg); + overflow-wrap: anywhere; +} + +.folder-pick-list { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; +} + +.folder-pick { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + text-align: start; +} + +.folder-pick span { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.upload-into { + margin: 0 0 var(--space-3); +}