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
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue