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

@ -1194,19 +1194,71 @@ impl App {
// so folders render above platform sections. Channels with
// a folder_id appear only in their folder; unfiled channels
// still get the per-platform grouping.
enum SidebarItem { FolderHeader(String, usize), FolderManageBtn, PlatformHeader(Platform), Channel(usize) }
// FolderHeader carries a `depth` for indentation so the
// N-level nesting reads as a tree.
enum SidebarItem { FolderHeader(String, usize, usize), FolderManageBtn, PlatformHeader(Platform), Channel(usize, usize) }
let mut items: Vec<SidebarItem> = Vec::new();
let has_any_folder_or_assignment = !self.folders.is_empty()
|| self.library.iter().any(|c| c.folder_id.is_some());
if has_any_folder_or_assignment {
items.push(SidebarItem::FolderManageBtn);
for f in &self.folders {
let members: Vec<usize> = self.library.iter().enumerate()
.filter(|(_, c)| c.folder_id == Some(f.id))
// Recursively emit a folder, its member channels, then
// its children. Count includes the whole subtree.
let folders = self.folders.clone();
let library = &self.library;
let subtree_count = |fid: i64| -> usize {
// Iterative DFS counting members of fid + descendants.
let mut seen = std::collections::HashSet::new();
let mut stack = vec![fid];
let mut n = 0usize;
while let Some(cur) = stack.pop() {
if !seen.insert(cur) { continue; }
n += library.iter().filter(|c| c.folder_id == Some(cur)).count();
for f in &folders {
if f.parent_id == Some(cur) { stack.push(f.id); }
}
}
n
};
// Walk the tree depth-first. `emit` is iterative to avoid
// borrow-checker gymnastics with a recursive closure.
let mut seen = std::collections::HashSet::new();
// Stack holds (folder_index_into_folders, depth).
let roots: Vec<usize> = folders.iter().enumerate()
.filter(|(_, f)| f.parent_id.is_none())
.map(|(i, _)| i)
.collect();
// Push roots in reverse so the first root pops first.
let mut stack: Vec<(usize, usize)> =
roots.into_iter().rev().map(|i| (i, 0usize)).collect();
while let Some((fi, depth)) = stack.pop() {
let f = &folders[fi];
if !seen.insert(f.id) { continue; }
items.push(SidebarItem::FolderHeader(f.name.clone(), subtree_count(f.id), depth));
for (i, c) in library.iter().enumerate() {
if c.folder_id == Some(f.id) {
items.push(SidebarItem::Channel(i, depth + 1));
}
}
// Push children (reverse for stable order).
let mut kids: Vec<usize> = folders.iter().enumerate()
.filter(|(_, c)| c.parent_id == Some(f.id))
.map(|(i, _)| i)
.collect();
items.push(SidebarItem::FolderHeader(f.name.clone(), members.len()));
for i in members { items.push(SidebarItem::Channel(i)); }
kids.reverse();
for ki in kids { stack.push((ki, depth + 1)); }
}
// Orphans whose parent was deleted: render at root.
for (fi, f) in folders.iter().enumerate() {
if !seen.contains(&f.id) {
items.push(SidebarItem::FolderHeader(f.name.clone(), subtree_count(f.id), 0));
for (i, c) in library.iter().enumerate() {
if c.folder_id == Some(f.id) {
items.push(SidebarItem::Channel(i, 1));
}
}
let _ = fi;
}
}
} else {
items.push(SidebarItem::FolderManageBtn);
@ -1218,7 +1270,8 @@ impl App {
items.push(SidebarItem::PlatformHeader(ch.platform));
last_platform_marker = Some(ch.platform);
}
items.push(SidebarItem::Channel(i));
// Unfiled channels render at the flat (depth 0) indent.
items.push(SidebarItem::Channel(i, 0));
}
for item in items {
@ -1231,9 +1284,11 @@ impl App {
}
});
}
SidebarItem::FolderHeader(name, count) => {
SidebarItem::FolderHeader(name, count, depth) => {
// Two spaces of base indent + 2 per nesting level.
let indent = " ".repeat(depth + 1);
ui.label(
egui::RichText::new(format!(" {name} ({count})"))
egui::RichText::new(format!("{indent}📁 {name} ({count})"))
.small()
.weak(),
);
@ -1245,7 +1300,7 @@ impl App {
.weak(),
);
}
SidebarItem::Channel(i) => {
SidebarItem::Channel(i, _depth) => {
let (name, total, has_playlists, size_bytes, channel_url, platform) = {
let ch = &self.library[i];
// Prefer the stored `.source-url` over folder-name guessing so
@ -1961,6 +2016,8 @@ impl App {
let mut to_rename: Option<(i64, String)> = None;
let mut to_delete: Option<(i64, String, usize)> = None;
let mut to_check: Option<i64> = None;
// (folder id, new parent or None) collected during render, applied after.
let mut to_reparent: Option<(i64, Option<i64>)> = None;
egui::Window::new("📁 Manage folders")
.open(&mut open)
.collapsible(false)
@ -1971,6 +2028,20 @@ impl App {
ui.label(egui::RichText::new("No folders yet — create one below.").weak());
}
let folders = self.folders.clone();
// Precompute each folder's descendant set so the parent
// picker can exclude choices that would create a cycle.
let descendants = |fid: i64| -> std::collections::HashSet<i64> {
let mut out = std::collections::HashSet::new();
let mut stack = vec![fid];
while let Some(cur) = stack.pop() {
for f in &folders {
if f.parent_id == Some(cur) && out.insert(f.id) {
stack.push(f.id);
}
}
}
out
};
for f in &folders {
let member_count = self.library.iter()
.filter(|c| c.folder_id == Some(f.id))
@ -1991,6 +2062,32 @@ impl App {
}
});
});
// Parent picker for nesting. Banned = self + descendants.
let banned = {
let mut b = descendants(f.id);
b.insert(f.id);
b
};
let current_parent_name = f.parent_id
.and_then(|pid| folders.iter().find(|o| o.id == pid))
.map(|o| o.name.as_str())
.unwrap_or("— top level —");
ui.horizontal(|ui| {
ui.label(egui::RichText::new(" ↳ parent:").small().weak());
egui::ComboBox::from_id_salt(("folder_parent", f.id))
.selected_text(current_parent_name)
.show_ui(ui, |ui| {
if ui.selectable_label(f.parent_id.is_none(), "— top level —").clicked() {
to_reparent = Some((f.id, None));
}
for o in &folders {
if banned.contains(&o.id) { continue; }
if ui.selectable_label(f.parent_id == Some(o.id), &o.name).clicked() {
to_reparent = Some((f.id, Some(o.id)));
}
}
});
});
ui.separator();
}
ui.add_space(6.0);
@ -2015,6 +2112,15 @@ impl App {
}
}
}
if let Some((id, new_parent)) = to_reparent {
match self.db.set_folder_parent(id, new_parent) {
Ok(()) => {
self.folders = self.db.list_folders().unwrap_or_default();
self.status = "Folder moved".to_string();
}
Err(e) => self.status = format!("Move folder: {e}"),
}
}
if let Some((id, old_name)) = to_rename {
// Inline rename: stash the id in a small confirm-style flow.
// For brevity in v1 the rename is handled via a system prompt-free

View file

@ -30,6 +30,8 @@ pub struct FolderRecord {
pub id: i64,
pub name: String,
pub position: i64,
/// Parent folder id for N-level nesting. `None` = top-level folder.
pub parent_id: Option<i64>,
}
/// In-memory representation of the `video_flags` table. Each set holds the
@ -185,6 +187,19 @@ impl Database {
PRIMARY KEY (target_kind, target_id)
);",
)?;
// ── Migration: folders.parent_id (N-level nesting) ───────────────
// `folders` predates nesting. Add the column idempotently — SQLite
// has no `ADD COLUMN IF NOT EXISTS`, so we attempt the ALTER and
// swallow the "duplicate column name" error that fires on an
// already-migrated DB. NULL parent_id = top-level folder.
let conn = self.conn();
match conn.execute("ALTER TABLE folders ADD COLUMN parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE", []) {
Ok(_) => {}
Err(rusqlite::Error::SqliteFailure(_, Some(msg)))
if msg.contains("duplicate column") => {}
Err(e) => return Err(e),
}
Ok(())
}
@ -276,17 +291,65 @@ impl Database {
/// has a deterministic order even before drag-to-reorder ships.
pub fn list_folders(&self) -> Result<Vec<FolderRecord>> {
let conn = self.conn();
let mut stmt = conn.prepare("SELECT id, name, position FROM folders ORDER BY position, id")?;
let mut stmt = conn.prepare("SELECT id, name, position, parent_id FROM folders ORDER BY position, id")?;
let rows = stmt.query_map([], |row| {
Ok(FolderRecord {
id: row.get(0)?,
name: row.get(1)?,
position: row.get(2)?,
parent_id: row.get(3)?,
})
})?;
Ok(rows.filter_map(std::result::Result::ok).collect())
}
/// Reparent a folder. `new_parent = None` makes it top-level.
///
/// Refuses to create a cycle: a folder can't become its own ancestor.
/// We walk up from the proposed new parent; if we hit `id` along the
/// way the move would form a loop and we return a friendly error
/// instead of corrupting the tree.
pub fn set_folder_parent(&self, id: i64, new_parent: Option<i64>) -> Result<()> {
let conn = self.conn();
if let Some(parent) = new_parent {
if parent == id {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("a folder can't be its own parent".into()),
));
}
// Walk ancestors of `parent`; if `id` appears, this would cycle.
let mut cur = Some(parent);
let mut guard = 0;
while let Some(c) = cur {
if c == id {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("that move would nest a folder inside its own descendant".into()),
));
}
// Defensive bound in case the table is already corrupt.
guard += 1;
if guard > 10_000 { break; }
cur = match conn.query_row(
"SELECT parent_id FROM folders WHERE id = ?1",
[c],
|r| r.get::<_, Option<i64>>(0),
) {
Ok(p) => p,
// Row gone (shouldn't happen mid-walk) → stop walking.
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e),
};
}
}
conn.execute(
"UPDATE folders SET parent_id = ?1 WHERE id = ?2",
rusqlite::params![new_parent, id],
)?;
Ok(())
}
/// Set or clear a channel's folder assignment. `folder_id = None`
/// deletes the row so the channel reverts to "Unfiled".
pub fn set_channel_folder(
@ -986,6 +1049,36 @@ mod restore_tests {
assert_eq!(db.get_all_notes().unwrap().len(), 1);
}
#[test]
fn folder_nesting_and_cycle_guard() {
let dir = ScratchDir::new("folder-nest");
let db = Database::open(&dir.join("nest.db")).unwrap();
let a = db.create_folder("A").unwrap();
let b = db.create_folder("B").unwrap();
let c = db.create_folder("C").unwrap();
// Nest B under A, C under B.
db.set_folder_parent(b, Some(a)).unwrap();
db.set_folder_parent(c, Some(b)).unwrap();
let folders = db.list_folders().unwrap();
let by_id = |id: i64| folders.iter().find(|f| f.id == id).unwrap();
assert_eq!(by_id(a).parent_id, None);
assert_eq!(by_id(b).parent_id, Some(a));
assert_eq!(by_id(c).parent_id, Some(b));
// A folder can't be its own parent.
assert!(db.set_folder_parent(a, Some(a)).is_err());
// Can't nest A under C (C is A's descendant → cycle).
assert!(db.set_folder_parent(a, Some(c)).is_err());
// Moving back to top level works.
db.set_folder_parent(c, None).unwrap();
assert_eq!(db.list_folders().unwrap().iter().find(|f| f.id == c).unwrap().parent_id, None);
}
#[test]
fn notes_merge_keeps_later_on_restore() {
let dir = ScratchDir::new("notes-merge");

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))

View file

@ -318,11 +318,14 @@ function renderSidebar(){
// Render a single channel entry (collapsed or expanded with sub-actions).
// Returned HTML is appended at the right point — used both in the
// folder loop and in the per-platform Unfiled loop below.
const renderChannel=(i)=>{
// `pad` is the left padding in px for nesting under a folder; defaults
// to the flat 10px used by top-level (Unfiled / platform) channels.
const renderChannel=(i,pad)=>{
const ch=library[i];
const active=activeChannelIdx===i&&activePlaylistIdx===null&&!showContinue;
const size=ch.size_bytes>0?' · '+fmtSize(ch.size_bytes):'';
let s=`<div class="ch-item${active?' active':''}" onclick="setView(${i},null)" title="${esc(ch.platform_label||'')}">${esc(ch.platform_icon||'')} ${esc(ch.name)} (${ch.total_videos}${size})</div>`;
const padStyle=pad!=null?`padding-left:${pad}px;`:'';
let s=`<div class="ch-item${active?' active':''}" style="${padStyle}" onclick="setView(${i},null)" title="${esc(ch.platform_label||'')}">${esc(ch.platform_icon||'')} ${esc(ch.name)} (${ch.total_videos}${size})</div>`;
if(activeChannelIdx===i&&!showContinue){
for(let pi=0;pi<ch.playlists.length;pi++){
const pl=ch.playlists[pi];
@ -342,25 +345,44 @@ function renderSidebar(){
// grouping for their members; Unfiled channels keep the platform
// sections below.
const folders=Array.isArray(librarySnapshotFolders)?librarySnapshotFolders:[];
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">${folders.length?'manage':'new'}</button>
</div>`;
if(folders.length){
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">manage</button>
</div>`;
for(const f of folders){
const members=[];
for(let i=0;i<library.length;i++){
if(library[i].folder_id===f.id)members.push(i);
}
const count=members.length;
h+=`<div class="sidebar-label" style="padding-left:8px;font-size:11px;color:var(--muted)">${esc(f.name)} (${count})</div>`;
for(const i of members)h+=renderChannel(i);
}
} else {
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">new</button>
</div>`;
// Recursively render the folder tree. parent_id===null (or a missing
// parent) marks a top-level folder; children indent one step further.
// A `seen` set guards against a malformed cycle in the data turning
// this into infinite recursion.
const childrenOf=(pid)=>folders.filter(f=>(f.parent_id??null)===pid);
// Count members including all descendant folders, so a parent folder's
// tally reflects everything nested under it.
const directMembers=(fid)=>{
const out=[];
for(let i=0;i<library.length;i++)if(library[i].folder_id===fid)out.push(i);
return out;
};
const subtreeCount=(fid,seen)=>{
if(seen.has(fid))return 0; seen.add(fid);
let n=directMembers(fid).length;
for(const c of childrenOf(fid))n+=subtreeCount(c.id,seen);
return n;
};
const renderFolderTree=(f,depth,seen)=>{
if(seen.has(f.id))return; seen.add(f.id);
const pad=8+depth*12;
const total=subtreeCount(f.id,new Set());
h+=`<div class="sidebar-label" style="padding-left:${pad}px;font-size:11px;color:var(--muted)">${'└ '.repeat(0)}${esc(f.name)} (${total})</div>`;
// Direct member channels of this folder, indented under it.
for(const i of directMembers(f.id))h+=renderChannel(i,pad+6);
// Then nested child folders.
for(const c of childrenOf(f.id))renderFolderTree(c,depth+1,seen);
};
const seen=new Set();
for(const f of childrenOf(null))renderFolderTree(f,0,seen);
// Safety net: render any orphaned folder whose parent_id points at a
// now-deleted row (shouldn't happen with the FK cascade, but cheap).
for(const f of folders)if(!seen.has(f.id))renderFolderTree(f,0,seen);
}
// Channels not assigned to a folder still get platform grouping.
@ -963,10 +985,25 @@ async function clearChannelOptions(platform,handle,btn){
async function openFolderManager(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const folders=librarySnapshotFolders||[];
// Build the set of descendant ids for each folder so the parent
// dropdown can grey out choices that would create a cycle (you can't
// nest a folder inside itself or any of its own descendants).
const childrenOf=(pid)=>folders.filter(f=>(f.parent_id??null)===pid);
const descendantsOf=(fid)=>{
const out=new Set(); const stack=[fid];
while(stack.length){const cur=stack.pop();
for(const c of childrenOf(cur)){if(!out.has(c.id)){out.add(c.id);stack.push(c.id);}}}
return out;
};
const rows=folders.map(f=>{
const memberCount=library.filter(c=>c.folder_id===f.id).length;
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<span style="flex:1">📁 ${esc(f.name)} <span style="color:var(--muted);font-size:11px">(${memberCount} channel${memberCount===1?'':'s'})</span></span>
const banned=descendantsOf(f.id); banned.add(f.id);
const parentOpts=[`<option value=""${(f.parent_id==null)?' selected':''}>— top level —</option>`]
.concat(folders.filter(o=>!banned.has(o.id)).map(o=>
`<option value="${o.id}"${f.parent_id===o.id?' selected':''}>${esc(o.name)}</option>`)).join('');
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);flex-wrap:wrap">
<span style="flex:1;min-width:120px">📁 ${esc(f.name)} <span style="color:var(--muted);font-size:11px">(${memberCount} channel${memberCount===1?'':'s'})</span></span>
<select onchange="setFolderParent(${f.id},this.value)" title="Nest this folder under another" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:3px 5px;font-size:11px;max-width:140px">${parentOpts}</select>
${memberCount>0?`<button onclick="checkFolder(${f.id},'${esc(f.name)}',this)" title="Check every channel in this folder for new videos">⬇ Check</button>`:''}
<button onclick="renameFolderPrompt(${f.id},'${esc(f.name)}')">Rename</button>
<button onclick="deleteFolderConfirm(${f.id},'${esc(f.name)}',${memberCount})" style="color:#f87171">Delete</button>
@ -1009,6 +1046,18 @@ async function renameFolderPrompt(id,oldName){
openFolderManager();
}catch(e){setStatus('Error: '+e.message)}
}
async function setFolderParent(id,parentVal){
// Empty string from the dropdown means "top level" → null parent.
const parent_id=parentVal===''?null:parseInt(parentVal,10);
try{
const r=await api(`/api/folders/${id}/parent`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({parent_id})});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder moved');
document.querySelector('.modal-bg')?.remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message);await loadLibrary();}
}
async function checkFolder(id,name,btn){
btn.disabled=true;
try{