- v23 迁移删除 section_block 表,以及 node 的 view_mode / empty_text / show_in_sidebar / allow_groups
- 重建 node 表并保留 44 条内容、15 个分组、5 张卡片,外键检查通过
- 种子库直接生成 card / card_group,不再依赖 section_block
- 删除旧 /api/catalog、/api/categories、/api/groups/{id} 与 categories 模块
- 分组接口不再接收/返回 format,前端 GroupNode 同步移除
- README/HANDOFF 更新到 v23;管理页提示改为『格式由每条内容决定』
256 lines
8.4 KiB
Rust
256 lines
8.4 KiB
Rust
//! 分组树的管理:多层分组、父级移动与排序。
|
||
//!
|
||
//! 数据层只有一张 `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<String>,
|
||
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
|
||
#[serde(default)]
|
||
pub icon: Option<String>,
|
||
/// 排序权重;不传则排到同级末尾
|
||
#[serde(default)]
|
||
pub sort: Option<i64>,
|
||
/// 是否启用,默认 true
|
||
#[serde(default)]
|
||
pub enabled: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct UpdateNode {
|
||
pub title: Option<String>,
|
||
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
|
||
pub parent_key: Option<String>,
|
||
pub sort: Option<i64>,
|
||
/// 图标:空字符串表示清除图标
|
||
pub icon: Option<String>,
|
||
pub enabled: Option<bool>,
|
||
}
|
||
|
||
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<String>, i64, Option<String>, 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<String>>(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(())
|
||
}
|