feat(api): 原子模型分组树 / 卡片 / catalog2
- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
//! 首页卡片:只存标题/顺序,内容由 `card_group` 勾选分组组成。
|
||||
//!
|
||||
//! 卡片与分组是多对多:一张卡可以放不同格式的分组,分组也可以被多张卡复用。
|
||||
//! 卡片本身不拥有数据,删卡不影响内容。
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{db::entries, db::map_error, error::StashError};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NewCard {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCard {
|
||||
pub title: Option<String>,
|
||||
pub sort: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CardGroupInput {
|
||||
pub group_key: String,
|
||||
#[serde(default)]
|
||||
pub preview_limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn load(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, key, title, sort FROM card ORDER BY sort, id")
|
||||
.map_err(map_error)?;
|
||||
|
||||
let cards = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let mut result = Vec::with_capacity(cards.len());
|
||||
for (id, key, title, sort) in cards {
|
||||
let mut group_stmt = conn
|
||||
.prepare(
|
||||
"SELECT group_key, preview_limit FROM card_group
|
||||
WHERE card_id = ?1 ORDER BY sort",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
let groups = group_stmt
|
||||
.query_map([id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<i64>>(1)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
result.push(json!({
|
||||
"id": id,
|
||||
"key": key,
|
||||
"title": title,
|
||||
"sort": sort,
|
||||
"groups": groups
|
||||
.into_iter()
|
||||
.map(|(group_key, preview_limit)| json!({
|
||||
"groupKey": group_key,
|
||||
"previewLimit": preview_limit,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn create(conn: &mut Connection, new: NewCard) -> Result<i64, StashError> {
|
||||
let title = new.title.trim();
|
||||
if title.is_empty() || title.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("卡片名称需为 1–40 个字".into()));
|
||||
}
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
let sort: i64 = tx
|
||||
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM card", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO card (key, title, sort) VALUES ('pending', ?1, ?2)",
|
||||
params![title, sort],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
tx.execute(
|
||||
"UPDATE card SET key = ?1 WHERE id = ?2",
|
||||
params![format!("card-{id}"), id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
entries::audit(&tx, "card.create", &format!("card-{id}"), title)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn update(conn: &mut Connection, id: i64, patch: UpdateCard) -> Result<(), StashError> {
|
||||
let current: Option<(String, i64)> = conn
|
||||
.query_row("SELECT title, sort FROM card WHERE id = ?1", [id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((current_title, current_sort)) = 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 sort = patch.sort.unwrap_or(current_sort);
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
tx.execute(
|
||||
"UPDATE card SET title = ?1, sort = ?2 WHERE id = ?3",
|
||||
params![title, sort, id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
entries::audit(&tx, "card.update", &format!("card-{id}"), &title)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(conn: &Connection, id: i64) -> Result<(), StashError> {
|
||||
let removed = conn
|
||||
.execute("DELETE FROM card WHERE id = ?1", [id])
|
||||
.map_err(map_error)?;
|
||||
if removed == 0 {
|
||||
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
|
||||
}
|
||||
entries::audit(conn, "card.delete", &format!("card-{id}"), "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 整组替换卡片勾选的分组(顺序即数组顺序)。
|
||||
pub fn set_groups(
|
||||
conn: &mut Connection,
|
||||
id: i64,
|
||||
groups: Vec<CardGroupInput>,
|
||||
) -> Result<(), StashError> {
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM card WHERE id = ?1", [id], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
if exists == 0 {
|
||||
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
|
||||
}
|
||||
|
||||
for group in &groups {
|
||||
let node_exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&group.group_key], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
if node_exists == 0 {
|
||||
return Err(StashError::NotFound(format!(
|
||||
"分组不存在: {}",
|
||||
group.group_key
|
||||
)));
|
||||
}
|
||||
if let Some(limit) = group.preview_limit {
|
||||
if !(1..=60).contains(&limit) {
|
||||
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
tx.execute("DELETE FROM card_group WHERE card_id = ?1", [id])
|
||||
.map_err(map_error)?;
|
||||
for (sort, group) in groups.iter().enumerate() {
|
||||
tx.execute(
|
||||
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, group.group_key, sort as i64, group.preview_limit],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
}
|
||||
entries::audit(&tx, "card.groups", &format!("card-{id}"), "")?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -53,6 +53,152 @@ pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 分组树节点(新视图用)
|
||||
struct TreeNode {
|
||||
key: String,
|
||||
parent: Option<String>,
|
||||
title: String,
|
||||
view_mode: Option<String>,
|
||||
icon: Option<String>,
|
||||
bar_color: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
sort: i64,
|
||||
}
|
||||
|
||||
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
|
||||
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
|
||||
FROM node ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let nodes = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TreeNode {
|
||||
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)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
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()
|
||||
.map(|entry| item_for(entry, format, tags))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let child_values = children
|
||||
.get(&Some(node.key.clone()))
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|child| render(conn, nodes, children, formats, tags, *child))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(json!({
|
||||
"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,
|
||||
"sort": node.sort,
|
||||
"entries": items,
|
||||
"children": child_values,
|
||||
}))
|
||||
}
|
||||
|
||||
let roots = children
|
||||
.get(&None)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Value::Array(roots))
|
||||
}
|
||||
|
||||
/// 全部条目标签(一次查询,树组装时用)
|
||||
fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT entry_key, label FROM entry_tag ORDER BY entry_key, sort")
|
||||
.map_err(map_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |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)
|
||||
}
|
||||
|
||||
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
|
||||
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
|
||||
+3
-2
@@ -242,9 +242,10 @@ fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError>
|
||||
|
||||
match (kind.as_str(), parent_kind.as_deref()) {
|
||||
("section", _) => Ok(()),
|
||||
("group", Some("section")) => Ok(()),
|
||||
// 多层分组:父级可以是分区或任意分组
|
||||
("group", Some(_)) => Ok(()),
|
||||
("group", _) => Err(StashError::InvalidDirectory(
|
||||
"分组的父节点必须是分区".into(),
|
||||
"分组的父节点必须是分区或分组".into(),
|
||||
)),
|
||||
_ => Err(StashError::InvalidDirectory(
|
||||
"条目只能挂在分区或分组下".into(),
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 分组树的管理:多层分组、格式、父级移动与排序。
|
||||
//!
|
||||
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带
|
||||
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
|
||||
//! 条目挂在任意节点上,格式由最近的根分组决定。
|
||||
|
||||
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 {
|
||||
/// 业务键,只允许小写字母、数字与连字符
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
/// 为空表示新建一个根分组
|
||||
#[serde(default)]
|
||||
pub parent_key: Option<String>,
|
||||
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[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 format: Option<String>,
|
||||
}
|
||||
|
||||
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, view_mode) = 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)
|
||||
}
|
||||
None => {
|
||||
let format = new.format.unwrap_or_else(|| "apps".into());
|
||||
if !FORMATS.contains(&format.as_str()) {
|
||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||||
}
|
||||
("section", Some(format))
|
||||
}
|
||||
};
|
||||
|
||||
let 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 tx = conn.transaction().map_err(map_error)?;
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar, allow_groups)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1)",
|
||||
params![key, new.parent_key, title, kind, view_mode, 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>, Option<String>, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT title, parent_key, view_mode, sort FROM node WHERE key = ?1",
|
||||
[key],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((current_title, current_parent, current_format, current_sort)) = 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 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 sort = patch.sort.unwrap_or(current_sort);
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
// 移动时重新排到新父级末尾;不移动则保留原顺序
|
||||
let final_sort = match patch.parent_key {
|
||||
Some(parent) => 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)?,
|
||||
None => sort,
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4 WHERE key = ?5",
|
||||
params![title, parent_key, format, final_sort, 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(())
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
//! 启动时会重新建表并灌入内嵌种子。
|
||||
|
||||
pub mod auth;
|
||||
pub mod cards;
|
||||
pub mod categories;
|
||||
mod catalog;
|
||||
pub mod entries;
|
||||
pub mod groups;
|
||||
mod schema;
|
||||
pub mod settings;
|
||||
|
||||
@@ -250,6 +252,58 @@ impl Db {
|
||||
categories::remove_group(&conn, id)
|
||||
}
|
||||
|
||||
// ---------- 原子模型:分组树 / 卡片 ----------
|
||||
|
||||
/// 完整分组树(多层,带条目)
|
||||
pub fn load_tree(&self) -> Result<Value, StashError> {
|
||||
let conn = self.lock()?;
|
||||
catalog::load_tree(&conn)
|
||||
}
|
||||
|
||||
pub fn load_cards(&self) -> Result<Vec<Value>, StashError> {
|
||||
let conn = self.lock()?;
|
||||
cards::load(&conn)
|
||||
}
|
||||
|
||||
pub fn create_node(&self, new: groups::NewNode) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
groups::create(&mut conn, new)
|
||||
}
|
||||
|
||||
pub fn update_node(&self, key: &str, patch: groups::UpdateNode) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
groups::update(&mut conn, key, patch)
|
||||
}
|
||||
|
||||
pub fn remove_node(&self, key: &str) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
groups::remove(&conn, key)
|
||||
}
|
||||
|
||||
pub fn create_card(&self, new: cards::NewCard) -> Result<i64, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
cards::create(&mut conn, new)
|
||||
}
|
||||
|
||||
pub fn update_card(&self, id: i64, patch: cards::UpdateCard) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
cards::update(&mut conn, id, patch)
|
||||
}
|
||||
|
||||
pub fn remove_card(&self, id: i64) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
cards::remove(&conn, id)
|
||||
}
|
||||
|
||||
pub fn set_card_groups(
|
||||
&self,
|
||||
id: i64,
|
||||
groups: Vec<cards::CardGroupInput>,
|
||||
) -> Result<(), StashError> {
|
||||
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)
|
||||
|
||||
+168
-1
@@ -4,7 +4,7 @@ use axum::{
|
||||
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, patch, post},
|
||||
routing::{get, patch, post, put},
|
||||
};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
||||
@@ -770,6 +770,167 @@ async fn delete_item(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 原子模型:分组树 / 卡片 ----------
|
||||
|
||||
/// GET /api/catalog2 —— 分组树 + 卡片(公开读,新首页/侧栏用)
|
||||
async fn catalog2_api(State(state): State<AppState>) -> Response {
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || -> Result<serde_json::Value, StashError> {
|
||||
Ok(serde_json::json!({
|
||||
"groups": db.load_tree()?,
|
||||
"cards": db.load_cards()?,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(payload)) => Json(payload).into_response(),
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_login(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
|
||||
if !same_origin(headers) {
|
||||
return Err(api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"));
|
||||
}
|
||||
match read_session(state, headers).await {
|
||||
Ok(session) if session.authenticated => Ok(()),
|
||||
Ok(_) => Err(api_error(StatusCode::UNAUTHORIZED, "请先登录")),
|
||||
Err(response) => Err(response),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_error(error: StashError) -> Response {
|
||||
match error {
|
||||
StashError::Conflict(message) => api_error(StatusCode::CONFLICT, &message),
|
||||
StashError::NotFound(message) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
StashError::InvalidDirectory(message) => api_error(StatusCode::BAD_REQUEST, &message),
|
||||
other => api_error(StatusCode::INTERNAL_SERVER_ERROR, &other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/groups —— 新建根分组 / 子分组
|
||||
async fn create_node(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::groups::NewNode>,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.create_node(body)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/groups/{key} —— 改名 / 移动 / 排序 / 根分组格式
|
||||
async fn update_node(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::groups::UpdateNode>,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.update_node(&key, body)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/groups/{key} —— 删除分组(级联子分组与条目)
|
||||
async fn delete_node(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.remove_node(&key)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/cards —— 新建卡片
|
||||
async fn create_card(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::cards::NewCard>,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.create_card(body)).await {
|
||||
Ok(Ok(id)) => Json(serde_json::json!({ "id": id })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/cards/{id} —— 卡片标题 / 顺序
|
||||
async fn update_card(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::cards::UpdateCard>,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.update_card(id, body)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/cards/{id} —— 删除卡片(不影响内容)
|
||||
async fn delete_card(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.remove_card(id)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/cards/{id}/groups —— 整组替换卡片勾选的分组
|
||||
async fn set_card_groups(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Vec<crate::db::cards::CardGroupInput>>,
|
||||
) -> Response {
|
||||
if let Err(response) = require_login(&state, &headers).await {
|
||||
return response;
|
||||
}
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.set_card_groups(id, body)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(error)) => node_error(error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/categories —— 新建分区(需要登录)
|
||||
async fn create_category(
|
||||
State(state): State<AppState>,
|
||||
@@ -955,6 +1116,12 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/projects", get(list_projects_api))
|
||||
.route("/api/projects/events", get(project_events))
|
||||
.route("/api/catalog", get(catalog_api))
|
||||
.route("/api/catalog2", get(catalog2_api))
|
||||
.route("/api/nodes", post(create_node))
|
||||
.route("/api/nodes/{key}", patch(update_node).delete(delete_node))
|
||||
.route("/api/cards", post(create_card))
|
||||
.route("/api/cards/{id}", patch(update_card).delete(delete_card))
|
||||
.route("/api/cards/{id}/groups", put(set_card_groups))
|
||||
.route("/api/catalog/events", get(catalog_events))
|
||||
.route("/api/auth/session", get(auth_session))
|
||||
.route("/api/auth/setup", post(auth_setup))
|
||||
|
||||
Reference in New Issue
Block a user