Channel folder hierarchy (Tartube parity 1.2)
Users can now organize channels into named folders independent of the
underlying platform groupings. v1 ships single-level (no nesting) since
that's where ~90% of the organizational value lives — nested folders +
drag-to-reparent + per-folder cascading options are deferred to v2 once
we have real-world feedback on what the workflow needs.
Data layer
- New `folders` SQLite table: id, name (UNIQUE), position, created_at.
- New `channel_assignments` table: (platform, handle) → folder_id with
ON DELETE CASCADE so deleting a folder cleanly unfiles its channels.
- Database methods: create_folder, rename_folder, delete_folder (with
PRAGMA foreign_keys ON for the cascade), list_folders,
set_channel_folder, get_all_channel_assignments.
- New `FolderRecord` struct (id/name/position) is `Serialize` so it
rides on the /api/library response.
Library
- `Channel` gains `folder_id: Option<i64>` (None = Unfiled).
- New `library::apply_channel_folders` mirror of `apply_channel_options`
populates it from the bulk DB map after every scan / rescan.
Web API
- `POST /api/folders` create with `{name}` → `{id}`.
- `POST /api/folders/:id/rename` rename with `{name}`.
- `DELETE /api/folders/:id` cascade-deletes assignments.
- `POST /api/channels/:platform/:handle/folder` upsert/clear via
`{folder_id: <i64|null>}`. All endpoints bump the library ETag.
- `/api/library` response now carries a `folders: [...]` array so the
client can render the sidebar grouping without a second round trip.
Web UI
- Sidebar gains a "📁 Folders" section above the platform groupings.
Each folder lists its member channels (with platform icon prefix);
channels with `folder_id` are pulled out of their platform section
to avoid duplication. Unfiled channels keep platform grouping.
- "manage" / "new" button next to the Folders heading opens a modal
with the full folder list (member counts inline) plus rename /
delete buttons and a Create input.
- New per-channel sub-action "📁 Move to folder…" opens a small
picker modal with one row per folder + "— Unfiled —". Current
assignment highlighted.
Desktop UI
- Sidebar refactored to build a `Vec<SidebarItem>` ordered list
(FolderHeader / FolderManageBtn / PlatformHeader / Channel) and
iterate it once, instead of the previous flat
`for i in 0..library.len()` loop. Folder section renders above
the platform sections; channels with `folder_id` are skipped from
the platform-grouped loop so they appear in exactly one place.
- New `folder_manager_window` + `move_to_folder_window` egui windows
cover create / rename (via the create-buffer field) / delete and
per-channel folder picking. Right-click context menu on a channel
gains "📁 Move to folder…".
- App struct gains: `folders`, `show_folder_manager`,
`folder_create_buffer`, `show_move_to_folder`, `move_to_folder_target`.
Tartube spec checklist
- Folder hierarchy box ticked (with the v2 deferrals noted in the
inline annotation).
55 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8281246420
commit
4b0e4b3b07
6 changed files with 652 additions and 21 deletions
112
src/database.rs
112
src/database.rs
|
|
@ -22,6 +22,16 @@ use std::path::Path;
|
|||
type Pool = r2d2::Pool<SqliteConnectionManager>;
|
||||
type PooledConn = r2d2::PooledConnection<SqliteConnectionManager>;
|
||||
|
||||
/// Persisted folder row from the `folders` table. Used by the sidebar +
|
||||
/// folder-management UI; channels reference folders via the separate
|
||||
/// `channel_assignments` table.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct FolderRecord {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
/// In-memory representation of the `video_flags` table. Each set holds the
|
||||
/// video IDs that have the named flag enabled — kept small (a few hundred
|
||||
/// to a few thousand entries in practice).
|
||||
|
|
@ -129,6 +139,19 @@ impl Database {
|
|||
waiting INTEGER NOT NULL DEFAULT 0,
|
||||
archive INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_assignments (
|
||||
platform TEXT NOT NULL,
|
||||
handle TEXT NOT NULL,
|
||||
folder_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (platform, handle),
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
@ -192,6 +215,95 @@ impl Database {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new folder with the given name. Returns the new folder's id.
|
||||
/// Trying to insert a duplicate name surfaces the SQLite UNIQUE error.
|
||||
pub fn create_folder(&self, name: &str) -> Result<i64> {
|
||||
let conn = self.conn();
|
||||
conn.execute("INSERT INTO folders (name) VALUES (?1)", [name])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Rename an existing folder. No-op when the new name already matches.
|
||||
pub fn rename_folder(&self, id: i64, new_name: &str) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
conn.execute("UPDATE folders SET name = ?1 WHERE id = ?2", rusqlite::params![new_name, id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a folder. Associated channel_assignments rows cascade-delete
|
||||
/// via the foreign-key constraint, so each member channel reverts to
|
||||
/// "Unfiled".
|
||||
pub fn delete_folder(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
// Enable FK cascade for this connection — SQLite has it off by default.
|
||||
conn.execute("PRAGMA foreign_keys = ON", [])?;
|
||||
conn.execute("DELETE FROM folders WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List every folder, ordered by `position` then `id` so the sidebar
|
||||
/// 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 rows = stmt.query_map([], |row| {
|
||||
Ok(FolderRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
position: row.get(2)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(std::result::Result::ok).collect())
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
platform: &str,
|
||||
handle: &str,
|
||||
folder_id: Option<i64>,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
match folder_id {
|
||||
Some(fid) => {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO channel_assignments (platform, handle, folder_id)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![platform, handle, fid],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"DELETE FROM channel_assignments WHERE platform = ?1 AND handle = ?2",
|
||||
[platform, handle],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk fetch of every channel's folder assignment as a
|
||||
/// `((platform, handle) → folder_id)` map. Used by the library scanner
|
||||
/// to populate `Channel.folder_id` after a rescan.
|
||||
pub fn get_all_channel_assignments(&self) -> Result<HashMap<(String, String), i64>> {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT platform, handle, folder_id FROM channel_assignments",
|
||||
)?;
|
||||
let map = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
|
||||
row.get::<_, i64>(2)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|(k, v)| (k, v))
|
||||
.collect();
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Bulk fetch every video's flag set, grouped by flag. Used at startup +
|
||||
/// after rescan to hydrate the in-memory caches. The four returned sets
|
||||
/// hold the IDs of videos with each flag set to true.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue