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