//! 首页卡片:只存标题/顺序,内容由 `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, pub sort: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CardGroupInput { pub group_key: String, #[serde(default)] pub preview_limit: Option, } pub fn load(conn: &Connection) -> Result, 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::, _>>() .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>(1)?, )) }) .map_err(map_error)? .collect::, _>>() .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::>(), })); } Ok(result) } pub fn create(conn: &mut Connection, new: NewCard) -> Result { 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, String, i64)> = conn .query_row( "SELECT key, title, sort FROM card WHERE id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() .map_err(map_error)?; let Some((current_key, current_title, current_sort)) = current else { return Err(StashError::NotFound(format!("卡片不存在: {id}"))); }; if current_key == "local-service" && patch.title.is_some() { return Err(StashError::InvalidDirectory( "本地服务卡片由系统生成,不能改名".into(), )); } 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 key: Option = conn .query_row("SELECT key FROM card WHERE id = ?1", [id], |row| row.get(0)) .optional() .map_err(map_error)?; if key.as_deref() == Some("local-service") { return Err(StashError::InvalidDirectory( "本地服务卡片由系统生成,不能删除".into(), )); } 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, ) -> Result<(), StashError> { let key: Option = conn .query_row("SELECT key FROM card WHERE id = ?1", [id], |row| row.get(0)) .optional() .map_err(map_error)?; if key.as_deref() == Some("local-service") { return Err(StashError::InvalidDirectory( "本地服务卡片由系统生成,不能配置分组".into(), )); } 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(()) }