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(())
|
||||
}
|
||||
Reference in New Issue
Block a user