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:
parent
91ca20d687
commit
23f7ba7ddd
4 changed files with 309 additions and 33 deletions
28
src/web.rs
28
src/web.rs
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue