N-level folder nesting (1.2+)

Folders were flat — one level under each platform. Tartube supports
arbitrary nesting; now we do too.

## Schema

Add `folders.parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE`
via an idempotent migration (ALTER TABLE, swallow the duplicate-column
error on already-migrated DBs). NULL = top-level. Cascade means deleting
a parent removes its subtree's folder rows; member channels revert to
Unfiled via the existing channel_assignments cascade.

## DB

- FolderRecord gains parent_id (flows to both UIs via serde).
- set_folder_parent(id, parent) reparents, with a cycle guard: walks the
  ancestor chain of the proposed parent and refuses if `id` appears
  (you can't nest a folder inside its own descendant). Self-parent also
  rejected. Returns a friendly SQLITE_CONSTRAINT error the web layer
  maps to 400.

## API

POST /api/folders/:id/parent { parent_id } — reparent or null for top.

## Web UI

- Sidebar renders the folder tree recursively: each folder indents one
  step under its parent, member channels indent under their folder, and
  the count shown is the whole subtree. A seen-set guards against any
  malformed cycle in the data.
- Folder manager gains a per-row "parent" dropdown; choices that would
  cycle (self + descendants) are excluded.

## Desktop UI

- Channel-panel sidebar builds the same tree with an iterative DFS
  (depth-indented folder headers, subtree counts).
- Folder manager gains a parent ComboBox per row with the same
  cycle-exclusion.

Verified the migration runs against an existing real DB (parent_id added,
no data loss). 1 new test covers nesting + both cycle cases; 80 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-31 21:28:10 -07:00
parent 91ca20d687
commit 23f7ba7ddd
4 changed files with 309 additions and 33 deletions

View file

@ -1766,6 +1766,11 @@ struct FolderCreateBody { name: String }
#[derive(Deserialize)]
struct FolderRenameBody { name: String }
/// Body for `POST /api/folders/:id/parent`. `parent_id = null` moves the
/// folder to the top level.
#[derive(Deserialize)]
struct FolderParentBody { parent_id: Option<i64> }
#[derive(Deserialize)]
struct AssignFolderBody { folder_id: Option<i64> }
@ -1807,6 +1812,28 @@ async fn post_rename_folder(
}
}
/// `POST /api/folders/:id/parent` — reparent a folder for nesting.
/// `parent_id = null` makes it top-level. The DB layer rejects cycles
/// (a folder can't become its own descendant) with a 400.
async fn post_folder_parent(
State(state): State<Arc<WebState>>,
Path(id): Path<i64>,
Json(body): Json<FolderParentBody>,
) -> impl IntoResponse {
match state.db.set_folder_parent(id, body.parent_id) {
Ok(()) => {
bump_library_version(&state);
(StatusCode::OK, "ok").into_response()
}
// A constraint failure here is the cycle guard — surface it as a
// 400 with the friendly message the DB layer attached.
Err(rusqlite::Error::SqliteFailure(_, Some(msg))) => {
(StatusCode::BAD_REQUEST, msg).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(),
}
}
/// `DELETE /api/folders/:id` — drop the folder. Member channels revert
/// to "Unfiled" via the foreign-key cascade.
async fn delete_folder(
@ -2403,6 +2430,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/notes/:kind/:id", post(post_note))
.route("/api/folders", post(post_create_folder))
.route("/api/folders/:id/rename", post(post_rename_folder))
.route("/api/folders/:id/parent", post(post_folder_parent))
.route("/api/folders/:id/check", post(post_check_folder))
.route("/api/backup/db", get(get_backup_db))
.route("/api/restore/db", post(post_restore_db))