Files
stash/src/db/groups.rs
T
CNWei 27e411fd2c refactor(db): 删除旧分区展示块与兼容字段
- 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;管理页提示改为『格式由每条内容决定』
2026-11-02 20:24:08 +08:00

256 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 分组树的管理:多层分组、父级移动与排序。
//!
//! 数据层只有一张 `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(())
}