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;管理页提示改为『格式由每条内容决定』
This commit is contained in:
+12
-343
@@ -11,23 +11,10 @@ use rusqlite::Connection;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
db::{Catalog, map_error},
|
||||
db::map_error,
|
||||
error::StashError,
|
||||
};
|
||||
|
||||
/// 容器节点行:分区与分组共用
|
||||
struct NodeRow {
|
||||
key: String,
|
||||
title: String,
|
||||
view_mode: Option<String>,
|
||||
bar_color: Option<String>,
|
||||
icon: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
empty_text: Option<String>,
|
||||
show_in_sidebar: bool,
|
||||
allow_groups: bool,
|
||||
}
|
||||
|
||||
/// 条目行:与 `entry` 表的列一一对应
|
||||
struct EntryRow {
|
||||
key: String,
|
||||
@@ -47,19 +34,11 @@ struct EntryRow {
|
||||
likes: i64,
|
||||
}
|
||||
|
||||
/// 读出整个内容目录。
|
||||
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
||||
Ok(Catalog {
|
||||
categories: load_sections(conn)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 分组树节点(新视图用)
|
||||
struct TreeNode {
|
||||
key: String,
|
||||
parent: Option<String>,
|
||||
title: String,
|
||||
view_mode: Option<String>,
|
||||
icon: Option<String>,
|
||||
bar_color: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
@@ -67,11 +46,11 @@ struct TreeNode {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
|
||||
/// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。
|
||||
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled
|
||||
"SELECT key, parent_key, title, icon, bar_color, preview_limit, sort, enabled
|
||||
FROM node ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
@@ -82,12 +61,11 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
key: row.get(0)?,
|
||||
parent: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
view_mode: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
bar_color: row.get(5)?,
|
||||
preview_limit: row.get(6)?,
|
||||
sort: row.get(7)?,
|
||||
enabled: row.get(8)?,
|
||||
icon: row.get(3)?,
|
||||
bar_color: row.get(4)?,
|
||||
preview_limit: row.get(5)?,
|
||||
sort: row.get(6)?,
|
||||
enabled: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
@@ -96,48 +74,22 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let index: HashMap<String, usize> = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, node)| (node.key.clone(), idx))
|
||||
.collect();
|
||||
|
||||
// 子节点索引(按 sort 已排好序)
|
||||
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
|
||||
for (idx, node) in nodes.iter().enumerate() {
|
||||
children.entry(node.parent.clone()).or_default().push(idx);
|
||||
}
|
||||
|
||||
// 每个节点继承最近根分组的格式
|
||||
let mut formats: Vec<String> = vec![String::new(); nodes.len()];
|
||||
for idx in 0..nodes.len() {
|
||||
let mut cursor = Some(idx);
|
||||
let mut format = "apps".to_string();
|
||||
while let Some(current) = cursor {
|
||||
if let Some(mode) = &nodes[current].view_mode {
|
||||
format = mode.clone();
|
||||
break;
|
||||
}
|
||||
cursor = nodes[current]
|
||||
.parent
|
||||
.as_ref()
|
||||
.and_then(|parent| index.get(parent).copied());
|
||||
}
|
||||
formats[idx] = format;
|
||||
}
|
||||
|
||||
let tags = load_all_tags(conn)?;
|
||||
|
||||
fn render(
|
||||
conn: &Connection,
|
||||
nodes: &[TreeNode],
|
||||
children: &HashMap<Option<String>, Vec<usize>>,
|
||||
formats: &[String],
|
||||
tags: &HashMap<String, Vec<String>>,
|
||||
idx: usize,
|
||||
) -> Result<Value, StashError> {
|
||||
let node = &nodes[idx];
|
||||
let format = &formats[idx];
|
||||
let entries = load_node_entries(conn, &node.key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
@@ -148,7 +100,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
.get(&Some(node.key.clone()))
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|child| render(conn, nodes, children, formats, tags, *child))
|
||||
.map(|child| render(conn, nodes, children, tags, *child))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?
|
||||
@@ -158,7 +110,6 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
"key": node.key,
|
||||
"parentKey": node.parent,
|
||||
"title": node.title,
|
||||
"format": format,
|
||||
"icon": node.icon.clone().unwrap_or_default(),
|
||||
"barColor": node.bar_color.clone().unwrap_or_default(),
|
||||
"previewLimit": node.preview_limit,
|
||||
@@ -173,7 +124,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
.get(&None)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx))
|
||||
.map(|idx| render(conn, &nodes, &children, &tags, *idx))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?
|
||||
@@ -203,164 +154,6 @@ fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, Stas
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
|
||||
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups
|
||||
FROM node
|
||||
WHERE kind = 'section'
|
||||
ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let sections = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(NodeRow {
|
||||
key: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
view_mode: row.get(2)?,
|
||||
bar_color: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
preview_limit: row.get(5)?,
|
||||
empty_text: row.get(6)?,
|
||||
show_in_sidebar: row.get(7)?,
|
||||
allow_groups: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 先释放语句再发后续查询,避免同一连接上的借用冲突
|
||||
drop(stmt);
|
||||
|
||||
// 每个分区对应的首页卡片:展示哪个节点、几条
|
||||
let blocks = load_blocks(conn)?;
|
||||
|
||||
sections
|
||||
.iter()
|
||||
.map(|section| build_section(conn, section, blocks.get(§ion.key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 读出所有卡片配置,按推导出的分区业务键索引。
|
||||
///
|
||||
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
|
||||
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
|
||||
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT COALESCE(p.key, n.key) AS section_key,
|
||||
b.node_key,
|
||||
COALESCE(b.title, n.title),
|
||||
b.preview_limit
|
||||
FROM section_block b
|
||||
JOIN node n ON n.key = b.node_key
|
||||
LEFT JOIN node p ON p.key = n.parent_key
|
||||
ORDER BY b.sort, b.node_key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
for (section_key, node_key, title, preview_limit) in rows {
|
||||
let mut block = Map::new();
|
||||
block.insert("nodeKey".into(), json!(node_key));
|
||||
block.insert("title".into(), json!(title));
|
||||
if let Some(limit) = preview_limit {
|
||||
block.insert("previewLimit".into(), json!(limit));
|
||||
}
|
||||
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
|
||||
blocks.entry(section_key).or_insert(Value::Object(block));
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// 按 `view_mode` 分派到不同的组装方式。
|
||||
fn build_section(
|
||||
conn: &Connection,
|
||||
section: &NodeRow,
|
||||
block: Option<&Value>,
|
||||
) -> Result<Value, StashError> {
|
||||
let view_mode = section
|
||||
.view_mode
|
||||
.as_deref()
|
||||
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
|
||||
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(section.key));
|
||||
object.insert("kind".into(), json!(view_mode));
|
||||
object.insert("title".into(), json!(section.title));
|
||||
object.insert(
|
||||
"barColor".into(),
|
||||
json!(section.bar_color.clone().unwrap_or_default()),
|
||||
);
|
||||
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
|
||||
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
|
||||
object.insert("allowGroups".into(), json!(section.allow_groups));
|
||||
|
||||
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
||||
if let Some(limit) = section.preview_limit {
|
||||
object.insert("previewLimit".into(), json!(limit));
|
||||
}
|
||||
if let Some(empty_text) = §ion.empty_text {
|
||||
object.insert("emptyText".into(), json!(empty_text));
|
||||
}
|
||||
if let Some(block) = block {
|
||||
object.insert("block".into(), block.clone());
|
||||
}
|
||||
|
||||
match view_mode {
|
||||
"apps" | "posters" | "articles" => {}
|
||||
other => {
|
||||
return Err(StashError::Database(format!(
|
||||
"分区 {} 的 view_mode 非法: {other}",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺
|
||||
let tags = load_tags(conn, §ion.key)?;
|
||||
if section.allow_groups {
|
||||
object.insert(
|
||||
"groups".into(),
|
||||
build_groups(conn, section, &tags)?,
|
||||
);
|
||||
let direct = load_node_entries(conn, §ion.key)?;
|
||||
let items = direct
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, &tags))
|
||||
.collect::<Vec<_>>();
|
||||
object.insert("items".into(), json!(items));
|
||||
} else {
|
||||
object.insert("groups".into(), json!([]));
|
||||
// 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容
|
||||
let entries = load_section_entries(conn, §ion.key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, &tags))
|
||||
.collect::<Vec<_>>();
|
||||
object.insert("items".into(), json!(items));
|
||||
}
|
||||
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
/// 按条目自身的格式组装列表项。
|
||||
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
|
||||
let mut value = match entry.format.as_str() {
|
||||
@@ -374,55 +167,6 @@ fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
|
||||
value
|
||||
}
|
||||
|
||||
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
|
||||
fn build_groups(
|
||||
conn: &Connection,
|
||||
section: &NodeRow,
|
||||
tags: &HashMap<String, Vec<String>>,
|
||||
) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT key, title, show_in_sidebar FROM node
|
||||
WHERE kind = 'group' AND parent_key = ?1
|
||||
ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let groups = stmt
|
||||
.query_map([§ion.key], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let mut rendered_groups = Vec::with_capacity(groups.len());
|
||||
for (group_key, group_title, show_in_sidebar) in groups {
|
||||
let entries = load_node_entries(conn, &group_key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, tags))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rendered_groups.push(json!({
|
||||
// 对外用业务键,不用数字主键
|
||||
"id": group_key,
|
||||
"name": group_title,
|
||||
"showInSidebar": show_in_sidebar,
|
||||
"items": items,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Value::Array(rendered_groups))
|
||||
}
|
||||
|
||||
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
|
||||
fn app_item(entry: &EntryRow) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(entry.key));
|
||||
@@ -504,55 +248,9 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
|
||||
}
|
||||
}
|
||||
|
||||
/// 取某个分区下的全部条目,按 `sort` 升序。
|
||||
///
|
||||
/// 条目只存 `node_key`,所属分区由父节点推导:
|
||||
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
||||
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
|
||||
fn load_section_entries(
|
||||
conn: &Connection,
|
||||
section_key: &str,
|
||||
) -> Result<Vec<EntryRow>, StashError> {
|
||||
/// 取某个节点直接挂着的条目,按 `sort` 升序。
|
||||
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
||||
COALESCE(e.format, 'apps')
|
||||
FROM entry e
|
||||
JOIN node p ON p.key = e.node_key
|
||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
||||
ORDER BY e.sort, e.key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([section_key], |row| {
|
||||
Ok(EntryRow {
|
||||
key: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
icon: row.get(3)?,
|
||||
href: row.get(4)?,
|
||||
cover: row.get(5)?,
|
||||
banner: row.get(6)?,
|
||||
badge: row.get(7)?,
|
||||
comment: row.get(8)?,
|
||||
summary: row.get(9)?,
|
||||
body: row.get(10)?,
|
||||
hot: row.get(11)?,
|
||||
views: row.get(12)?,
|
||||
likes: row.get(13)?,
|
||||
format: row.get(14)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
||||
@@ -589,32 +287,3 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
|
||||
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT t.entry_key, t.label
|
||||
FROM entry_tag t
|
||||
JOIN entry e ON e.key = t.entry_key
|
||||
JOIN node p ON p.key = e.node_key
|
||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
||||
ORDER BY t.entry_key, t.sort",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([section_key], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (entry_key, label) in rows {
|
||||
grouped.entry(entry_key).or_default().push(label);
|
||||
}
|
||||
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
@@ -1,827 +0,0 @@
|
||||
//! 分区(首页卡片 / 侧栏项)的新增与删除。
|
||||
//!
|
||||
//! 分区就是 `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<String>,
|
||||
pub icon: Option<String>,
|
||||
pub bar_color: Option<String>,
|
||||
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
|
||||
pub kind: Option<String>,
|
||||
pub preview_limit: Option<i64>,
|
||||
pub empty_text: Option<String>,
|
||||
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
|
||||
pub block_node_key: Option<String>,
|
||||
/// 是否允许子分组(与类型无关,后台开关)
|
||||
pub allow_groups: Option<bool>,
|
||||
}
|
||||
|
||||
/// 新建分组(子分类)
|
||||
#[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<String>,
|
||||
pub show_in_sidebar: Option<bool>,
|
||||
}
|
||||
|
||||
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, allow_groups, sort, show_in_sidebar)
|
||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)",
|
||||
params![id, title, new.kind, i64::from(new.kind == "apps"), 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<String>,
|
||||
bar_color: Option<String>,
|
||||
view_mode: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
empty_text: Option<String>,
|
||||
allow_groups: bool,
|
||||
}
|
||||
|
||||
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, allow_groups
|
||||
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)?,
|
||||
allow_groups: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.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";
|
||||
let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups);
|
||||
|
||||
if is_apps && !was_apps && allow_groups {
|
||||
// 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」
|
||||
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)?;
|
||||
}
|
||||
|
||||
// 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位
|
||||
if !allow_groups {
|
||||
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, allow_groups = ?7
|
||||
WHERE key = ?8",
|
||||
params![
|
||||
title,
|
||||
icon,
|
||||
bar_color,
|
||||
kind,
|
||||
preview_limit,
|
||||
empty_text,
|
||||
allow_groups,
|
||||
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 allow_groups: Option<bool> = conn
|
||||
.query_row(
|
||||
"SELECT allow_groups FROM node WHERE key = ?1 AND kind = 'section'",
|
||||
[section_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some(allow_groups) = allow_groups else {
|
||||
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
||||
};
|
||||
if !allow_groups {
|
||||
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 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<String> = 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,
|
||||
allow_groups INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
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<String>, Option<String>, Option<i64>) =
|
||||
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_keeps_groups_until_disabled() {
|
||||
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("应能建条目");
|
||||
// create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置
|
||||
conn.execute("DELETE FROM section_block", []).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-g");
|
||||
|
||||
// 关闭子分组:卡片挪回分区
|
||||
update(
|
||||
&mut conn,
|
||||
"books",
|
||||
UpdateCategory {
|
||||
allow_groups: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("应能关闭");
|
||||
let block: String = conn
|
||||
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能查");
|
||||
assert_eq!(block, "books", "关闭子分组后卡片应指回分区");
|
||||
}
|
||||
|
||||
#[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_only_under_apps_sections() {
|
||||
let mut conn = db();
|
||||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||||
assert!(
|
||||
create_group(
|
||||
&mut conn,
|
||||
"films",
|
||||
NewGroup {
|
||||
id: "g1".into(),
|
||||
title: "片单".into()
|
||||
}
|
||||
)
|
||||
.is_err(),
|
||||
"未开启子分组的分区不能建组"
|
||||
);
|
||||
|
||||
// 打开「允许子分组」后,海报分区也可以建组
|
||||
update(
|
||||
&mut conn,
|
||||
"films",
|
||||
UpdateCategory {
|
||||
allow_groups: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.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()),
|
||||
allow_groups: Some(true),
|
||||
..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);
|
||||
}
|
||||
}
|
||||
+16
-52
@@ -1,16 +1,14 @@
|
||||
//! 分组树的管理:多层分组、格式、父级移动与排序。
|
||||
//! 分组树的管理:多层分组、父级移动与排序。
|
||||
//!
|
||||
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带
|
||||
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
|
||||
//! 条目挂在任意节点上,格式由最近的根分组决定。
|
||||
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
|
||||
//! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
|
||||
//! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{db::entries, db::map_error, error::StashError};
|
||||
|
||||
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewNode {
|
||||
@@ -20,9 +18,6 @@ pub struct NewNode {
|
||||
/// 为空表示新建一个根分组
|
||||
#[serde(default)]
|
||||
pub parent_key: Option<String>,
|
||||
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
@@ -41,8 +36,6 @@ pub struct UpdateNode {
|
||||
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
|
||||
pub parent_key: Option<String>,
|
||||
pub sort: Option<i64>,
|
||||
/// 只对根分组生效
|
||||
pub format: Option<String>,
|
||||
/// 图标:空字符串表示清除图标
|
||||
pub icon: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
@@ -78,7 +71,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
||||
return Err(StashError::Conflict(format!("分组已存在: {key}")));
|
||||
}
|
||||
|
||||
let (kind, view_mode) = match new.parent_key.as_deref() {
|
||||
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| {
|
||||
@@ -88,15 +81,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
||||
if parent_exists == 0 {
|
||||
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
|
||||
}
|
||||
("group", None)
|
||||
}
|
||||
None => {
|
||||
let format = new.format.unwrap_or_else(|| "apps".into());
|
||||
if !FORMATS.contains(&format.as_str()) {
|
||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||||
}
|
||||
("section", Some(format))
|
||||
"group"
|
||||
}
|
||||
None => "section",
|
||||
};
|
||||
|
||||
let default_sort: i64 = match new.parent_key.as_deref() {
|
||||
@@ -126,14 +113,13 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)",
|
||||
"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,
|
||||
view_mode,
|
||||
icon,
|
||||
enabled,
|
||||
sort
|
||||
@@ -146,9 +132,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
||||
}
|
||||
|
||||
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
|
||||
let current: Option<(String, Option<String>, Option<String>, i64, Option<String>, bool)> = conn
|
||||
let current: Option<(String, Option<String>, i64, Option<String>, bool)> = conn
|
||||
.query_row(
|
||||
"SELECT title, parent_key, view_mode, sort, icon, enabled FROM node WHERE key = ?1",
|
||||
"SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1",
|
||||
[key],
|
||||
|row| {
|
||||
Ok((
|
||||
@@ -157,15 +143,13 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) =
|
||||
current
|
||||
let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
|
||||
else {
|
||||
return Err(StashError::NotFound(format!("分组不存在: {key}")));
|
||||
};
|
||||
@@ -218,26 +202,6 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
|
||||
None => current_parent.clone(),
|
||||
};
|
||||
|
||||
let is_root = parent_key.is_none();
|
||||
let format = if is_root {
|
||||
match patch.format {
|
||||
Some(format) => {
|
||||
if !FORMATS.contains(&format.as_str()) {
|
||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||||
}
|
||||
Some(format)
|
||||
}
|
||||
None => current_format,
|
||||
}
|
||||
} else {
|
||||
// 子分组没有自己的格式,跟随根分组
|
||||
None
|
||||
};
|
||||
|
||||
if is_root && format.is_none() {
|
||||
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
|
||||
}
|
||||
|
||||
let icon = match patch.icon {
|
||||
Some(icon) => {
|
||||
let icon = icon.trim().to_string();
|
||||
@@ -268,10 +232,10 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4,
|
||||
icon = ?5, enabled = ?6
|
||||
WHERE key = ?7",
|
||||
params![title, parent_key, format, final_sort, icon, enabled, key],
|
||||
"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)?;
|
||||
|
||||
+94
-128
@@ -10,7 +10,6 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod cards;
|
||||
pub mod categories;
|
||||
mod catalog;
|
||||
pub mod entries;
|
||||
pub mod groups;
|
||||
@@ -34,15 +33,6 @@ pub use settings::{Settings, SettingsDocument};
|
||||
/// 条目编辑(P2)。
|
||||
pub use entries::{EntryPatch, EntryRecord};
|
||||
|
||||
/// 一次查询得到的完整内容目录。
|
||||
///
|
||||
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
||||
#[derive(Debug)]
|
||||
pub struct Catalog {
|
||||
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
|
||||
pub categories: Vec<Value>,
|
||||
}
|
||||
|
||||
/// 内容库句柄。
|
||||
///
|
||||
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
|
||||
@@ -107,12 +97,6 @@ impl Db {
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
|
||||
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
|
||||
let conn = self.lock()?;
|
||||
catalog::load(&conn)
|
||||
}
|
||||
|
||||
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
|
||||
///
|
||||
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
|
||||
@@ -217,41 +201,6 @@ impl Db {
|
||||
entries::set_like(&conn, key, liked)
|
||||
}
|
||||
|
||||
// ---------- 分区(首页卡片 / 侧栏项) ----------
|
||||
|
||||
pub fn create_category(&self, new: categories::NewCategory) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::create(&mut conn, new)
|
||||
}
|
||||
|
||||
pub fn update_category(
|
||||
&self,
|
||||
id: &str,
|
||||
patch: categories::UpdateCategory,
|
||||
) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::update(&mut conn, id, patch)
|
||||
}
|
||||
|
||||
pub fn create_group(
|
||||
&self,
|
||||
section_id: &str,
|
||||
new: categories::NewGroup,
|
||||
) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::create_group(&mut conn, section_id, new)
|
||||
}
|
||||
|
||||
pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
categories::update_group(&conn, id, patch)
|
||||
}
|
||||
|
||||
pub fn remove_group(&self, id: &str) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
categories::remove_group(&conn, id)
|
||||
}
|
||||
|
||||
// ---------- 原子模型:分组树 / 卡片 ----------
|
||||
|
||||
/// 完整分组树(多层,带条目)
|
||||
@@ -303,11 +252,6 @@ impl Db {
|
||||
let mut conn = self.lock()?;
|
||||
cards::set_groups(&mut conn, id, groups)
|
||||
}
|
||||
|
||||
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::remove(&mut conn, id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||
@@ -363,8 +307,8 @@ mod tests {
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
let blocks: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能统计展示块");
|
||||
.query_row("SELECT COUNT(*) FROM card", [], |row| row.get(0))
|
||||
.expect("应能统计首页卡片");
|
||||
let tags: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
|
||||
.expect("应能统计标签");
|
||||
@@ -383,27 +327,26 @@ mod tests {
|
||||
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
|
||||
// 正文是一篇一段 Markdown,只有 12 篇文章有
|
||||
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
|
||||
assert_eq!(blocks, 3, "每个分区一个首页展示块");
|
||||
assert_eq!(blocks, 3, "每个分区一张首页卡片");
|
||||
}
|
||||
|
||||
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。
|
||||
/// 分区与分组是同一张表:分组必须挂在分区下。
|
||||
#[test]
|
||||
fn groups_are_nodes_under_their_section() {
|
||||
let conn = seeded();
|
||||
|
||||
let (parent_key, view_mode): (String, Option<String>) = conn
|
||||
let parent_key: String = conn
|
||||
.query_row(
|
||||
"SELECT p.key, g.view_mode
|
||||
"SELECT p.key
|
||||
FROM node g
|
||||
JOIN node p ON p.key = g.parent_key
|
||||
WHERE g.kind = 'group' AND g.key = 'ui'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查到分组及其父节点");
|
||||
|
||||
assert_eq!(parent_key, "recommend");
|
||||
assert!(view_mode.is_none(), "分组不应带 view_mode");
|
||||
|
||||
// 分区一定是顶层节点
|
||||
let root_sections: i64 = conn
|
||||
@@ -419,92 +362,102 @@ mod tests {
|
||||
#[test]
|
||||
fn catalog_matches_the_frontend_model() {
|
||||
let conn = seeded();
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
let categories = &catalog.categories;
|
||||
let tree = catalog::load_tree(&conn).expect("应能组装分组树");
|
||||
let roots = tree.as_array().expect("根节点应为数组");
|
||||
assert_eq!(roots.len(), 3);
|
||||
|
||||
assert_eq!(categories.len(), 3);
|
||||
let apps = roots
|
||||
.iter()
|
||||
.find(|node| node["key"] == "recommend")
|
||||
.expect("应有常用推荐");
|
||||
let groups = apps["children"].as_array().expect("应有子分组");
|
||||
assert_eq!(groups.len(), 5);
|
||||
|
||||
let apps = &categories[0];
|
||||
assert_eq!(apps["id"], "recommend");
|
||||
assert_eq!(apps["kind"], "apps");
|
||||
assert_eq!(apps["barColor"], "#3858f6");
|
||||
assert_eq!(apps["showInSidebar"], true);
|
||||
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
|
||||
|
||||
// 常用站点只作为首页卡片展示,不进侧栏
|
||||
assert_eq!(
|
||||
apps["groups"][0]["showInSidebar"], false,
|
||||
"常用站点不应出现在侧栏"
|
||||
);
|
||||
assert_eq!(apps["groups"][1]["showInSidebar"], true);
|
||||
|
||||
let first_app = &apps["groups"][0]["items"][0];
|
||||
let common = groups
|
||||
.iter()
|
||||
.find(|node| node["key"] == "common")
|
||||
.expect("应有常用站点");
|
||||
let first_app = &common["entries"][0];
|
||||
assert_eq!(first_app["name"], "GitHub");
|
||||
assert_eq!(first_app["format"], "apps");
|
||||
assert_eq!(first_app["hot"], true);
|
||||
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
|
||||
assert_eq!(first_app["views"], 429);
|
||||
|
||||
let posters = &categories[1];
|
||||
assert_eq!(posters["kind"], "posters");
|
||||
// 海报与图文用 title 而不是 name
|
||||
assert_eq!(
|
||||
posters["items"][0].get("name"),
|
||||
None,
|
||||
let posters = roots
|
||||
.iter()
|
||||
.find(|node| node["key"] == "movie")
|
||||
.expect("应有推荐电影");
|
||||
let first_poster = &posters["entries"][0];
|
||||
assert_eq!(first_poster["format"], "posters");
|
||||
assert!(
|
||||
first_poster.get("name").is_none(),
|
||||
"海报不应同时下发 name 字段"
|
||||
);
|
||||
assert!(posters["items"][0]["title"].is_string());
|
||||
// tags 来自 entry_tag 子表
|
||||
assert!(first_poster["title"].is_string());
|
||||
assert_eq!(
|
||||
posters["items"][0]["tags"]
|
||||
first_poster["tags"]
|
||||
.as_array()
|
||||
.expect("海报应有标签")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
let articles = &categories[2];
|
||||
assert_eq!(articles["kind"], "articles");
|
||||
// 正文是一整段 Markdown,而不是块数组
|
||||
let body = articles["items"][0]["body"]
|
||||
.as_str()
|
||||
.expect("图文应有正文");
|
||||
let articles = roots
|
||||
.iter()
|
||||
.find(|node| node["key"] == "ces")
|
||||
.expect("应有 CES 现场报道");
|
||||
let first_article = &articles["entries"][0];
|
||||
assert_eq!(first_article["format"], "articles");
|
||||
let body = first_article["body"].as_str().expect("图文应有正文");
|
||||
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
|
||||
assert!(body.contains("!["), "正文应含配图语法: {body}");
|
||||
assert!(body.contains("\n\n"), "段落之间用空行分隔");
|
||||
assert!(
|
||||
articles["items"][0].get("blocks").is_none(),
|
||||
first_article.get("blocks").is_none(),
|
||||
"不应再下发 blocks 数组"
|
||||
);
|
||||
// meta 与海报的 tags 共用 entry_tag 表
|
||||
assert_eq!(
|
||||
articles["items"][0]["meta"]
|
||||
first_article["meta"]
|
||||
.as_array()
|
||||
.expect("图文应有元信息")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
// ---- 首页展示块 ----
|
||||
// 常用推荐卡片展示「常用站点」分组,最多 4 条
|
||||
assert_eq!(apps["block"]["nodeKey"], "common");
|
||||
assert_eq!(apps["block"]["previewLimit"], 4);
|
||||
// 电影/文章没有分组,块直接指向分区自己
|
||||
assert_eq!(posters["block"]["nodeKey"], "movie");
|
||||
assert_eq!(articles["block"]["nodeKey"], "ces");
|
||||
// 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条
|
||||
let (group_key, preview_limit): (String, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT cg.group_key, cg.preview_limit
|
||||
FROM card c JOIN card_group cg ON cg.card_id = c.id
|
||||
WHERE c.key = 'card-recommend'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("应能查到常用推荐卡片");
|
||||
assert_eq!(group_key, "common");
|
||||
assert_eq!(preview_limit, Some(4));
|
||||
}
|
||||
|
||||
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
|
||||
/// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。
|
||||
#[test]
|
||||
fn section_blocks_cascade_with_their_section() {
|
||||
fn card_groups_cascade_with_their_group() {
|
||||
let conn = seeded();
|
||||
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
|
||||
.expect("应能删除分区");
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM node WHERE kind = 'group' AND key = 'common' AND parent_key = 'recommend'",
|
||||
[],
|
||||
)
|
||||
.expect("应能删除分组");
|
||||
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能统计展示块");
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM card_group WHERE group_key = 'common'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计卡片分组");
|
||||
|
||||
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个");
|
||||
assert_eq!(remaining, 0, "卡片里对 common 的勾选应被级联删除");
|
||||
}
|
||||
|
||||
/// 删除分组节点时,组内条目也要一起删掉。
|
||||
@@ -534,7 +487,7 @@ mod tests {
|
||||
fn primary_key_is_id_and_key_is_unique() {
|
||||
let conn = seeded();
|
||||
|
||||
for table in ["node", "entry", "section_block"] {
|
||||
for table in ["node", "entry", "card"] {
|
||||
let pk: String = conn
|
||||
.query_row(
|
||||
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
|
||||
@@ -576,21 +529,22 @@ mod tests {
|
||||
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
|
||||
.expect("应能改分区业务键");
|
||||
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
let apps = catalog
|
||||
.categories
|
||||
let tree = catalog::load_tree(&conn).expect("应能组装分组树");
|
||||
let roots = tree.as_array().expect("根节点应为数组");
|
||||
let apps = roots
|
||||
.iter()
|
||||
.find(|category| category["id"] == "recommend-v2")
|
||||
.find(|node| node["key"] == "recommend-v2")
|
||||
.expect("应能找到改名后的分区");
|
||||
|
||||
let total: usize = apps["groups"]
|
||||
.as_array()
|
||||
.expect("应有分组")
|
||||
.iter()
|
||||
.map(|group| group["items"].as_array().expect("应有条目").len())
|
||||
.sum();
|
||||
fn count(node: &serde_json::Value) -> usize {
|
||||
let own = node["entries"].as_array().map_or(0, |items| items.len());
|
||||
let children = node["children"]
|
||||
.as_array()
|
||||
.map_or(0, |items| items.iter().map(count).sum());
|
||||
own + children
|
||||
}
|
||||
|
||||
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
|
||||
assert_eq!(count(apps), 18, "改名后条目仍应归在这个分区下");
|
||||
}
|
||||
|
||||
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
|
||||
@@ -654,7 +608,19 @@ mod tests {
|
||||
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
||||
fresh
|
||||
.execute_batch(
|
||||
"CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, title TEXT, kind TEXT, view_mode TEXT);
|
||||
"CREATE TABLE node (
|
||||
key TEXT PRIMARY KEY,
|
||||
parent_key TEXT,
|
||||
title TEXT,
|
||||
kind TEXT,
|
||||
view_mode TEXT,
|
||||
bar_color TEXT,
|
||||
icon TEXT,
|
||||
preview_limit INTEGER,
|
||||
empty_text TEXT,
|
||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
||||
CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0);
|
||||
PRAGMA user_version = 15;",
|
||||
|
||||
+67
-64
@@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json");
|
||||
/// 种子会自动重新灌一份干净数据。
|
||||
const SCHEMA_SQL: &str = r#"
|
||||
-- 内容节点:分区与分组共用一张自引用树
|
||||
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
|
||||
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
|
||||
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
|
||||
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_key 必须为 NULL
|
||||
-- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
|
||||
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
|
||||
@@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node (
|
||||
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
|
||||
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
|
||||
-- 容器层级
|
||||
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
|
||||
-- 分区渲染形态;分组为 NULL
|
||||
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
|
||||
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
|
||||
bar_color TEXT, -- 标题左侧色条颜色
|
||||
icon TEXT, -- 字符图标
|
||||
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
|
||||
empty_text TEXT, -- 分区为空时的提示文案
|
||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
|
||||
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
||||
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
|
||||
-- 是否允许子分组(与类型无关,后台开关)
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
-- 是否启用(禁用后前台侧栏/卡片不展示)
|
||||
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
|
||||
CHECK (
|
||||
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
|
||||
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
|
||||
(kind = 'section' AND parent_key IS NULL)
|
||||
OR (kind = 'group' AND parent_key IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -95,18 +87,6 @@ CREATE TABLE IF NOT EXISTS entry_tag (
|
||||
PRIMARY KEY (entry_key, sort)
|
||||
);
|
||||
|
||||
-- 首页卡片:一行一个卡片,说明展示哪个节点
|
||||
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
|
||||
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
|
||||
CREATE TABLE IF NOT EXISTS section_block (
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
|
||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 要展示的节点业务键
|
||||
title TEXT, -- 块标题;NULL 时用节点 title
|
||||
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
|
||||
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
|
||||
);
|
||||
|
||||
-- ========== v16:登录与设置 ==========
|
||||
|
||||
-- 管理员:单用户场景只允许一行
|
||||
@@ -226,6 +206,41 @@ const V22_SQL: &str = r#"
|
||||
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
|
||||
"#;
|
||||
|
||||
/// v22 → v23:删除旧分区展示块与 node 上的兼容字段,node 只保留当前模型需要的列。
|
||||
const V23_SQL: &str = r#"
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
DROP TABLE IF EXISTS section_block;
|
||||
|
||||
CREATE TABLE node_new (
|
||||
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 CHECK (kind IN ('section', 'group')),
|
||||
bar_color TEXT,
|
||||
icon TEXT,
|
||||
preview_limit INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
CHECK (
|
||||
(kind = 'section' AND parent_key IS NULL)
|
||||
OR (kind = 'group' AND parent_key IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO node_new (key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort)
|
||||
SELECT key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort
|
||||
FROM node;
|
||||
|
||||
DROP TABLE node;
|
||||
ALTER TABLE node_new RENAME TO node;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
"#;
|
||||
|
||||
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
|
||||
const V21_SQL: &str = r#"
|
||||
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
|
||||
@@ -302,7 +317,7 @@ pub struct SeedReport {
|
||||
///
|
||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||
pub const SCHEMA_VERSION: i64 = 22;
|
||||
pub const SCHEMA_VERSION: i64 = 23;
|
||||
|
||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
@@ -372,6 +387,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
version = 22;
|
||||
}
|
||||
|
||||
if version == 22 {
|
||||
conn.execute_batch(V23_SQL).map_err(map_error)?;
|
||||
version = 23;
|
||||
}
|
||||
|
||||
conn.pragma_update(None, "user_version", version)
|
||||
.map_err(map_error)?;
|
||||
|
||||
@@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort)
|
||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"INSERT INTO node (key, parent_key, title, kind, bar_color, icon, preview_limit, sort)
|
||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
section.key,
|
||||
section.title,
|
||||
view,
|
||||
section.bar_color,
|
||||
section.icon,
|
||||
section.preview_limit,
|
||||
section.empty_text,
|
||||
i64::from(section.show_in_sidebar),
|
||||
i64::from(view == "apps"),
|
||||
section.sort,
|
||||
],
|
||||
)
|
||||
@@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
if view == "apps" {
|
||||
for group in §ion.groups {
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort)
|
||||
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
|
||||
params![
|
||||
group.key,
|
||||
section.key,
|
||||
group.title,
|
||||
i64::from(group.show_in_sidebar),
|
||||
group.sort
|
||||
],
|
||||
"INSERT INTO node (key, parent_key, title, kind, sort)
|
||||
VALUES (?1, ?2, ?3, 'group', ?4)",
|
||||
params![group.key, section.key, group.title, group.sort],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.groups += 1;
|
||||
@@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
}
|
||||
}
|
||||
|
||||
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
|
||||
// 首页卡片:仓库里直接生成卡片与勾选的分组
|
||||
tx.execute(
|
||||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
"INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
section.block.node_key,
|
||||
section.block.title,
|
||||
section.block.preview_limit,
|
||||
format!("card-{}", section.key),
|
||||
section
|
||||
.block
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| section.title.clone()),
|
||||
section.sort,
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
let card_id = tx.last_insert_rowid();
|
||||
tx.execute(
|
||||
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
|
||||
VALUES (?1, ?2, 0, ?3)",
|
||||
params![card_id, section.block.node_key, section.block.preview_limit],
|
||||
)
|
||||
.map_err(|error| {
|
||||
StashError::Database(format!(
|
||||
"分区 {} 的展示节点 {} 无效: {error}",
|
||||
@@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
report.blocks += 1;
|
||||
}
|
||||
|
||||
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
|
||||
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
Ok(report)
|
||||
@@ -565,11 +581,6 @@ struct SeedSection {
|
||||
sort: i64,
|
||||
#[serde(default)]
|
||||
preview_limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
empty_text: Option<String>,
|
||||
/// 是否出现在侧栏;默认 true
|
||||
#[serde(default = "default_true")]
|
||||
show_in_sidebar: bool,
|
||||
/// 仅 apps 分区使用
|
||||
#[serde(default)]
|
||||
groups: Vec<SeedGroup>,
|
||||
@@ -601,17 +612,9 @@ struct SeedGroup {
|
||||
key: String,
|
||||
title: String,
|
||||
sort: i64,
|
||||
/// 是否出现在侧栏;默认 true
|
||||
#[serde(default = "default_true")]
|
||||
show_in_sidebar: bool,
|
||||
entries: Vec<SeedEntry>,
|
||||
}
|
||||
|
||||
/// serde 默认值:侧栏默认显示
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 一条内容:字段与 `entry` 表的列一一对应。
|
||||
///
|
||||
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
|
||||
|
||||
Reference in New Issue
Block a user