//! 分区(首页卡片 / 侧栏项)的新增与删除。 //! //! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。 //! 新增时顺手建一条首页展示块,保证它立刻出现在首页。 use rusqlite::{Connection, OptionalExtension, params}; use serde::Deserialize; use crate::{db::map_error, error::StashError, db::entries}; const KINDS: [&str; 3] = ["apps", "posters", "articles"]; #[derive(Debug, Clone, Deserialize)] pub struct NewCategory { /// 业务键,只允许小写字母、数字与连字符 pub id: String, pub title: String, /// apps / posters / articles pub kind: String, } /// 修改分区:字段都可选,只改传进来的。 #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateCategory { pub title: Option, pub icon: Option, pub bar_color: Option, /// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属 pub kind: Option, pub preview_limit: Option, pub empty_text: Option, /// 首页卡片预览哪个节点(分区自己或它下面的分组) pub block_node_key: Option, } /// 新建分组(子分类) #[derive(Debug, Clone, Deserialize)] pub struct NewGroup { pub id: String, pub title: String, } /// 修改分组:改名 / 侧栏显隐 #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateGroup { pub title: Option, pub show_in_sidebar: Option, } fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 32 && id .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') } pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> { let id = new.id.trim(); let title = new.title.trim(); if !valid_id(id) { return Err(StashError::InvalidDirectory( "标识只能用小写字母、数字与连字符(≤32)".into(), )); } if title.is_empty() || title.chars().count() > 40 { return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into())); } if !KINDS.contains(&new.kind.as_str()) { return Err(StashError::InvalidDirectory("类型不合法".into())); } let exists: i64 = conn .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0)) .map_err(map_error)?; if exists > 0 { return Err(StashError::Conflict(format!("分区已存在: {id}"))); } let tx = conn.transaction().map_err(map_error)?; let sort: i64 = tx .query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'", [], |row| row.get(0), ) .map_err(map_error)?; tx.execute( "INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar) VALUES (?1, NULL, ?2, 'section', ?3, ?4, 1)", params![id, title, new.kind, sort], ) .map_err(map_error)?; let block_sort: i64 = tx .query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| row.get(0), ) .map_err(map_error)?; tx.execute( "INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)", params![id, block_sort], ) .map_err(map_error)?; entries::audit(&tx, "category.create", id, title)?; tx.commit().map_err(map_error)?; Ok(()) } struct SectionRow { title: String, icon: Option, bar_color: Option, view_mode: Option, preview_limit: Option, empty_text: Option, } fn valid_color(color: &str) -> bool { if color.is_empty() { return true; } let hex = color.strip_prefix('#').unwrap_or(""); (hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit()) } /// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。 /// /// 改类型时迁移数据,保证切换后条目不丢: /// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去, /// 首页卡片也改指向该分组; /// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。 pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> { if id == "local" { return Err(StashError::InvalidDirectory( "「本地服务」是运行时分类,不能编辑".into(), )); } let tx = conn.transaction().map_err(map_error)?; let current = tx .query_row( "SELECT title, icon, bar_color, view_mode, preview_limit, empty_text FROM node WHERE key = ?1 AND kind = 'section'", [id], |row| { Ok(SectionRow { title: row.get(0)?, icon: row.get(1)?, bar_color: row.get(2)?, view_mode: row.get(3)?, preview_limit: row.get(4)?, empty_text: row.get(5)?, }) }, ) .optional() .map_err(map_error)?; let Some(current) = current else { return Err(StashError::NotFound(format!("分区不存在: {id}"))); }; let title = match patch.title { Some(title) => { let title = title.trim().to_string(); if title.is_empty() || title.chars().count() > 40 { return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into())); } title } None => current.title, }; let icon = match patch.icon { Some(icon) => { let icon = icon.trim().to_string(); if icon.chars().count() > 8 { return Err(StashError::InvalidDirectory("图标最多 8 个字符".into())); } (!icon.is_empty()).then_some(icon) } None => current.icon, }; let bar_color = match patch.bar_color { Some(color) => { let color = color.trim().to_ascii_lowercase(); if !valid_color(&color) { return Err(StashError::InvalidDirectory( "色条需为 #rgb 或 #rrggbb".into(), )); } (!color.is_empty()).then_some(color) } None => current.bar_color, }; let empty_text = match patch.empty_text { Some(text) => { let text = text.trim().to_string(); (!text.is_empty()).then_some(text) } None => current.empty_text, }; let preview_limit = match patch.preview_limit { Some(limit) => { if !(1..=60).contains(&limit) { return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into())); } Some(limit) } None => current.preview_limit, }; let kind = match patch.kind { Some(kind) => { if !KINDS.contains(&kind.as_str()) { return Err(StashError::InvalidDirectory("类型不合法".into())); } kind } None => current .view_mode .clone() .ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?, }; let was_apps = current.view_mode.as_deref() == Some("apps"); let is_apps = kind == "apps"; if is_apps && !was_apps { // 切到应用:条目必须有分组可挂;没有就建一个「全部」 let group_count: i64 = tx .query_row( "SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1", [id], |row| row.get(0), ) .map_err(map_error)?; let group_key = if group_count == 0 { let group_key = format!("{id}-all"); tx.execute( "INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar) VALUES (?1, ?2, '全部', 'group', 1, 1)", params![group_key, id], ) .map_err(map_error)?; group_key } else { tx.query_row( "SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1 ORDER BY sort, key LIMIT 1", [id], |row| row.get(0), ) .map_err(map_error)? }; tx.execute( "UPDATE entry SET node_key = ?1 WHERE node_key = ?2", params![group_key, id], ) .map_err(map_error)?; tx.execute( "UPDATE section_block SET node_key = ?1 WHERE node_key = ?2", params![group_key, id], ) .map_err(map_error)?; } else if !is_apps && was_apps { // 离开应用:首页卡片从「某个分组」挪回分区,标题交还给分区 tx.execute( "UPDATE section_block SET node_key = ?1, title = NULL WHERE node_key IN ( SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1 )", [id], ) .map_err(map_error)?; } tx.execute( "UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4, preview_limit = ?5, empty_text = ?6 WHERE key = ?7", params![title, icon, bar_color, kind, preview_limit, empty_text, id], ) .map_err(map_error)?; // 首页卡片预览哪个节点:分区自己或它下面的分组 if let Some(block_node_key) = patch.block_node_key { let block_node_key = block_node_key.trim().to_string(); let valid: i64 = tx .query_row( "SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)", params![block_node_key, id], |row| row.get(0), ) .map_err(map_error)?; if valid == 0 { return Err(StashError::InvalidDirectory( "首页卡片只能预览本分区或它的分组".into(), )); } let updated = tx .execute( "UPDATE section_block SET node_key = ?1, title = NULL WHERE id IN ( SELECT b.id FROM section_block b JOIN node n ON n.key = b.node_key LEFT JOIN node p ON p.key = n.parent_key WHERE COALESCE(p.key, n.key) = ?2 )", params![block_node_key, id], ) .map_err(map_error)?; if updated == 0 { let sort: i64 = tx .query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| { row.get(0) }) .map_err(map_error)?; tx.execute( "INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)", params![block_node_key, sort], ) .map_err(map_error)?; } } entries::audit(&tx, "category.update", id, &title)?; tx.commit().map_err(map_error)?; Ok(()) } /// 在应用类分区下新建分组(子分类)。 pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> { let id = new.id.trim(); let title = new.title.trim(); if !valid_id(id) { return Err(StashError::InvalidDirectory( "分组标识只能用小写字母、数字与连字符(≤32)".into(), )); } if title.is_empty() || title.chars().count() > 40 { return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into())); } let section_exists: Option = conn .query_row( "SELECT key FROM node WHERE key = ?1 AND kind = 'section'", [section_id], |row| row.get(0), ) .optional() .map_err(map_error)?; if section_exists.is_none() { return Err(StashError::NotFound(format!("分区不存在: {section_id}"))); } let exists: i64 = conn .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| { row.get(0) }) .map_err(map_error)?; if exists > 0 { return Err(StashError::Conflict(format!("分组已存在: {id}"))); } let sort: i64 = conn .query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1", [section_id], |row| row.get(0), ) .map_err(map_error)?; conn.execute( "INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar) VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)", params![id, section_id, title, sort], ) .map_err(map_error)?; entries::audit(conn, "group.create", id, title)?; Ok(()) } /// 修改分组:改名 / 侧栏显隐。 pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> { let current: Option = conn .query_row( "SELECT title FROM node WHERE key = ?1 AND kind = 'group'", [id], |row| row.get(0), ) .optional() .map_err(map_error)?; let Some(current) = current else { return Err(StashError::NotFound(format!("分组不存在: {id}"))); }; let title = match patch.title { Some(title) => { let title = title.trim().to_string(); if title.is_empty() || title.chars().count() > 40 { return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into())); } title } None => current, }; match patch.show_in_sidebar { Some(show) => conn.execute( "UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3", params![title, show, id], ), None => conn.execute( "UPDATE node SET title = ?1 WHERE key = ?2", params![title, id], ), } .map_err(map_error)?; entries::audit(conn, "group.update", id, &title)?; Ok(()) } /// 删除分组:组内条目会一起删除(前端需二次确认)。 pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> { let removed = conn .execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id]) .map_err(map_error)?; if removed == 0 { return Err(StashError::NotFound(format!("分组不存在: {id}"))); } entries::audit(conn, "group.delete", id, "")?; Ok(()) } pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> { if id == "local" { return Err(StashError::InvalidDirectory("本地服务不能删除".into())); } let removed = conn .execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id]) .map_err(map_error)?; if removed == 0 { return Err(StashError::NotFound(format!("分区不存在: {id}"))); } entries::audit(conn, "category.delete", id, "")?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn db() -> Connection { let conn = Connection::open_in_memory().expect("内存库应能打开"); conn.execute_batch( "PRAGMA foreign_keys = ON; CREATE TABLE node ( id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE, title TEXT NOT NULL, kind TEXT NOT NULL, view_mode TEXT, icon TEXT, bar_color TEXT, preview_limit INTEGER, empty_text TEXT, sort INTEGER NOT NULL DEFAULT 0, show_in_sidebar INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE entry ( key TEXT PRIMARY KEY, node_key TEXT NOT NULL ); CREATE TABLE section_block ( id INTEGER PRIMARY KEY, node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, action TEXT NOT NULL, target TEXT, detail TEXT );", ) .expect("应能建表"); conn } #[test] fn create_then_delete() { let mut conn = db(); create( &mut conn, NewCategory { id: "books".into(), title: "书单".into(), kind: "posters".into(), }, ) .expect("应能创建"); let blocks: i64 = conn .query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .expect("应能查"); assert_eq!(blocks, 1); remove(&mut conn, "books").expect("应能删除"); let nodes: i64 = conn .query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0)) .expect("应能查"); assert_eq!(nodes, 0); } #[test] fn rejects_bad_id_and_duplicate() { let mut conn = db(); assert!( create( &mut conn, NewCategory { id: "Bad Id".into(), title: "x".into(), kind: "apps".into() } ) .is_err() ); create( &mut conn, NewCategory { id: "books".into(), title: "书单".into(), kind: "apps".into(), }, ) .expect("应能创建"); let error = create( &mut conn, NewCategory { id: "books".into(), title: "书单".into(), kind: "apps".into(), }, ) .expect_err("重复应失败"); assert!(matches!(error, StashError::Conflict(_))); } fn section(id: &str, title: &str, kind: &str) -> NewCategory { NewCategory { id: id.into(), title: title.into(), kind: kind.into(), } } #[test] fn updates_fields_and_rejects_bad_values() { let mut conn = db(); create(&mut conn, section("books", "书单", "posters")).expect("应能创建"); update( &mut conn, "books", UpdateCategory { title: Some("读过的书".into()), icon: Some("📚".into()), bar_color: Some("#ff6034".into()), preview_limit: Some(8), ..Default::default() }, ) .expect("应能更新"); let (title, icon, color, limit): (String, Option, Option, Option) = conn.query_row( "SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .expect("应能查"); assert_eq!(title, "读过的书"); assert_eq!(icon.as_deref(), Some("📚")); assert_eq!(color.as_deref(), Some("#ff6034")); assert_eq!(limit, Some(8)); assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err()); assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err()); assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err()); assert!(matches!( update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"), StashError::NotFound(_) )); } #[test] fn switching_apps_to_posters_moves_card_back_to_section() { let mut conn = db(); create(&mut conn, section("books", "书单", "apps")).expect("应能创建"); conn.execute( "INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)", [], ) .expect("应能建分组"); conn.execute( "INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", [], ) .expect("应能建条目"); conn.execute( "INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)", [], ) .expect("应能建卡片"); update(&mut conn, "books", UpdateCategory { kind: Some("posters".into()), ..Default::default() }) .expect("应能改类型"); let block: String = conn .query_row("SELECT node_key FROM section_block", [], |row| row.get(0)) .expect("应能查"); assert_eq!(block, "books", "卡片应挪回分区"); let mode: String = conn .query_row("SELECT view_mode FROM node WHERE key='books'", [], |row| row.get(0)) .expect("应能查"); assert_eq!(mode, "posters"); } #[test] fn creates_updates_and_removes_group() { let mut conn = db(); create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建"); create_group( &mut conn, "apps1", NewGroup { id: "g1".into(), title: "常用".into(), }, ) .expect("应能建分组"); update_group( &conn, "g1", UpdateGroup { title: Some("改名".into()), show_in_sidebar: Some(false), }, ) .expect("应能改分组"); let (title, show): (String, i64) = conn .query_row( "SELECT title, show_in_sidebar FROM node WHERE key='g1'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .expect("应能查"); assert_eq!(title, "改名"); assert_eq!(show, 0); remove_group(&conn, "g1").expect("应能删分组"); let left: i64 = conn .query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| { row.get(0) }) .expect("应能查"); assert_eq!(left, 0); } #[test] fn groups_under_any_kind_of_section() { let mut conn = db(); create(&mut conn, section("films", "电影", "posters")).expect("应能创建"); create_group( &mut conn, "films", NewGroup { id: "g1".into(), title: "片单".into(), }, ) .expect("海报分区也应能建分组"); } #[test] fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db(); create(&mut conn, section("films", "电影", "posters")).expect("应能创建"); conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", []) .expect("应能建条目"); conn.execute( "INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)", [], ) .expect("应能建卡片"); update(&mut conn, "films", UpdateCategory { kind: Some("apps".into()), ..Default::default() }) .expect("应能改类型"); let (entry_node, block_node): (String, String) = conn .query_row( "SELECT (SELECT node_key FROM entry WHERE key='f1'), (SELECT node_key FROM section_block)", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .expect("应能查"); assert_eq!(entry_node, "films-all", "条目应挂到默认分组"); assert_eq!(block_node, "films-all", "首页卡片应指向默认分组"); let groups: i64 = conn .query_row( "SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'", [], |row| row.get(0), ) .expect("应能查"); assert_eq!(groups, 1); } }