//! 分组树的管理:多层分组、父级移动与排序。 //! //! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`), //! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。 //! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。 use rusqlite::{Connection, OptionalExtension, params}; use serde::Deserialize; use crate::{db::entries, db::map_error, error::StashError}; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NewNode { /// 业务键,只允许小写字母、数字与连字符 pub key: String, pub title: String, /// 为空表示新建一个根分组 #[serde(default)] pub parent_key: Option, /// 图标:可以是 iconfont 名或字符;空字符串表示不显示 #[serde(default)] pub icon: Option, /// 排序权重;不传则排到同级末尾 #[serde(default)] pub sort: Option, /// 是否启用,默认 true #[serde(default)] pub enabled: Option, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateNode { pub title: Option, /// 传入即移动到新的父节点;不支持移回根(根分组请直接新建) pub parent_key: Option, pub sort: Option, /// 图标:空字符串表示清除图标 pub icon: Option, pub enabled: Option, } fn valid_key(key: &str) -> bool { !key.is_empty() && key.len() <= 64 && key .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') } pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> { let key = new.key.trim(); let title = new.title.trim(); if !valid_key(key) { return Err(StashError::InvalidDirectory( "分组标识只能用小写字母、数字与连字符(≤64)".into(), )); } if title.is_empty() || title.chars().count() > 40 { return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into())); } let exists: i64 = conn .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [key], |row| { row.get(0) }) .map_err(map_error)?; if exists > 0 { return Err(StashError::Conflict(format!("分组已存在: {key}"))); } let kind = match new.parent_key.as_deref() { Some(parent) => { let parent_exists: i64 = conn .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| { row.get(0) }) .map_err(map_error)?; if parent_exists == 0 { return Err(StashError::NotFound(format!("父分组不存在: {parent}"))); } "group" } None => "section", }; let default_sort: i64 = match new.parent_key.as_deref() { Some(parent) => conn .query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1", [parent], |row| row.get(0), ) .map_err(map_error)?, None => conn .query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key IS NULL", [], |row| row.get(0), ) .map_err(map_error)?, }; let sort = new.sort.unwrap_or(default_sort); let icon = new .icon .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); let enabled = new.enabled.unwrap_or(true); let tx = conn.transaction().map_err(map_error)?; tx.execute( "INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ key, new.parent_key, title, kind, icon, enabled, sort ], ) .map_err(map_error)?; entries::audit(&tx, "node.create", key, title)?; tx.commit().map_err(map_error)?; Ok(()) } pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> { let current: Option<(String, Option, i64, Option, bool)> = conn .query_row( "SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1", [key], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) }, ) .optional() .map_err(map_error)?; let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current else { return Err(StashError::NotFound(format!("分组不存在: {key}"))); }; 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 parent_key = match patch.parent_key.clone() { Some(parent) => { if parent == key { return Err(StashError::InvalidDirectory("不能把分组移到自己下面".into())); } let exists: i64 = conn .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&parent], |row| { row.get(0) }) .map_err(map_error)?; if exists == 0 { return Err(StashError::NotFound(format!("父分组不存在: {parent}"))); } // 防止把节点移进自己的子孙 let mut cursor = Some(parent.clone()); while let Some(candidate) = cursor { if candidate == key { return Err(StashError::InvalidDirectory( "不能把分组移到自己的子分组下面".into(), )); } cursor = conn .query_row( "SELECT parent_key FROM node WHERE key = ?1", [candidate], |row| row.get::<_, Option>(0), ) .optional() .map_err(map_error)? .flatten(); } Some(parent) } // 不传表示保持原父级(不支持移回根) None => current_parent.clone(), }; let icon = match patch.icon { Some(icon) => { let icon = icon.trim().to_string(); (!icon.is_empty()).then_some(icon) } None => current_icon, }; let enabled = patch.enabled.unwrap_or(current_enabled); let sort = patch.sort.unwrap_or(current_sort); let tx = conn.transaction().map_err(map_error)?; // 只有真正换了父级才排到末尾;同一父级下编辑排序权重时按传入值保存 let parent_changed = patch .parent_key .as_deref() .is_some_and(|parent| Some(parent) != current_parent.as_deref()); let final_sort = if parent_changed { let parent = patch.parent_key.as_deref().unwrap_or_default(); tx.query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2", params![parent, key], |row| row.get::<_, i64>(0), ) .map_err(map_error)? } else { sort }; tx.execute( "UPDATE node SET title = ?1, parent_key = ?2, sort = ?3, icon = ?4, enabled = ?5 WHERE key = ?6", params![title, parent_key, final_sort, icon, enabled, key], ) .map_err(map_error)?; entries::audit(&tx, "node.update", key, &title)?; tx.commit().map_err(map_error)?; Ok(()) } pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> { let removed = conn .execute("DELETE FROM node WHERE key = ?1", [key]) .map_err(map_error)?; if removed == 0 { return Err(StashError::NotFound(format!("分组不存在: {key}"))); } entries::audit(conn, "node.delete", key, "")?; Ok(()) }