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
126
src/app.rs
126
src/app.rs
|
|
@ -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();
|
||||
items.push(SidebarItem::FolderHeader(f.name.clone(), members.len()));
|
||||
for i in members { items.push(SidebarItem::Channel(i)); }
|
||||
// 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();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
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))
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue