//! 内容库的查询组装:把统一的 node 树还原成前端 `types.ts` 想要的三种形态。 //! //! 库里只有一套容器树(分区 → 分组),前端需要 `apps / posters / articles` //! 的可辨识联合,那是**查询层按 `node.view_mode` 组装**的事。 //! //! 所有关联都走业务键(`key`),SQL 里能直接看懂指向的是哪个分区 / 分组。 use std::collections::HashMap; use rusqlite::Connection; use serde_json::{Map, Value, json}; use crate::{ db::map_error, error::StashError, }; /// 条目行:与 `entry` 表的列一一对应 struct EntryRow { key: String, format: String, name: String, description: Option, icon: Option, href: Option, cover: Option, banner: Option, badge: Option, comment: Option, summary: Option, body: Option, hot: bool, views: i64, likes: i64, } /// 分组树节点(新视图用) struct TreeNode { key: String, parent: Option, title: String, icon: Option, bar_color: Option, preview_limit: Option, sort: i64, enabled: bool, } /// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。 pub fn load_tree(conn: &Connection) -> Result { let mut stmt = conn .prepare( "SELECT key, parent_key, title, icon, bar_color, preview_limit, sort, enabled 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)?, 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)? .collect::, _>>() .map_err(map_error)?; drop(stmt); // 子节点索引(按 sort 已排好序) let mut children: HashMap, Vec> = HashMap::new(); for (idx, node) in nodes.iter().enumerate() { children.entry(node.parent.clone()).or_default().push(idx); } let tags = load_all_tags(conn)?; fn render( conn: &Connection, nodes: &[TreeNode], children: &HashMap, Vec>, tags: &HashMap>, idx: usize, ) -> Result { let node = &nodes[idx]; let entries = load_node_entries(conn, &node.key)?; let items = entries .iter() .map(|entry| item_for(entry, tags)) .collect::>(); let child_values = children .get(&Some(node.key.clone())) .map(|list| { list.iter() .map(|child| render(conn, nodes, children, tags, *child)) .collect::, _>>() }) .transpose()? .unwrap_or_default(); Ok(json!({ "key": node.key, "parentKey": node.parent, "title": node.title, "icon": node.icon.clone().unwrap_or_default(), "barColor": node.bar_color.clone().unwrap_or_default(), "previewLimit": node.preview_limit, "sort": node.sort, "enabled": node.enabled, "entries": items, "children": child_values, })) } let roots = children .get(&None) .map(|list| { list.iter() .map(|idx| render(conn, &nodes, &children, &tags, *idx)) .collect::, _>>() }) .transpose()? .unwrap_or_default(); Ok(Value::Array(roots)) } /// 全部条目标签(一次查询,树组装时用) fn load_all_tags(conn: &Connection) -> Result>, 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::, _>>() .map_err(map_error)?; let mut grouped: HashMap> = HashMap::new(); for (entry_key, label) in rows { grouped.entry(entry_key).or_default().push(label); } Ok(grouped) } /// 按条目自身的格式组装列表项。 fn item_for(entry: &EntryRow, tags: &HashMap>) -> Value { let mut value = match entry.format.as_str() { "posters" => poster_item(entry, tags.get(&entry.key)), "articles" => article_item(entry, tags.get(&entry.key)), _ => app_item(entry), }; if let Some(object) = value.as_object_mut() { object.insert("format".into(), json!(entry.format)); } value } fn app_item(entry: &EntryRow) -> Value { let mut object = Map::new(); object.insert("id".into(), json!(entry.key)); object.insert("name".into(), json!(entry.name)); // 列表卡片必须有副标题,空字符串比缺字段安全 object.insert( "desc".into(), json!(entry.description.clone().unwrap_or_default()), ); object.insert("icon".into(), json!(entry.icon.clone().unwrap_or_default())); object.insert( "href".into(), json!(entry.href.clone().unwrap_or_else(|| "#".to_string())), ); insert_optional(&mut object, "cover", &entry.cover); insert_optional(&mut object, "banner", &entry.banner); insert_optional(&mut object, "badge", &entry.badge); insert_optional(&mut object, "comment", &entry.comment); // hot 只在为真时下发,前端判 `item.hot === true` if entry.hot { object.insert("hot".into(), json!(true)); } object.insert("views".into(), json!(entry.views)); object.insert("likes".into(), json!(entry.likes)); Value::Object(object) } /// 海报条目:前端用 `title` 而不是 `name`。 fn poster_item(entry: &EntryRow, tags: Option<&Vec>) -> Value { let mut object = Map::new(); object.insert("id".into(), json!(entry.key)); object.insert("title".into(), json!(entry.name)); object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default())); if let Some(tags) = tags.filter(|tags| !tags.is_empty()) { object.insert("tags".into(), json!(tags)); } insert_optional(&mut object, "comment", &entry.comment); insert_optional(&mut object, "summary", &entry.summary); insert_optional(&mut object, "href", &entry.href); object.insert("views".into(), json!(entry.views)); object.insert("likes".into(), json!(entry.likes)); Value::Object(object) } /// 图文条目:`meta` 来自 entry_tag,`body` 是 Markdown 正文。 fn article_item(entry: &EntryRow, meta: Option<&Vec>) -> Value { let mut object = Map::new(); object.insert("id".into(), json!(entry.key)); object.insert("title".into(), json!(entry.name)); object.insert( "desc".into(), json!(entry.description.clone().unwrap_or_default()), ); object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default())); if let Some(meta) = meta.filter(|meta| !meta.is_empty()) { object.insert("meta".into(), json!(meta)); } if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) { object.insert("body".into(), json!(body)); } insert_optional(&mut object, "href", &entry.href); Value::Object(object) } /// 可选字段:`None` 就不下发,前端用 `??` 兜底。 fn insert_optional(object: &mut Map, key: &str, value: &Option) { if let Some(value) = value { object.insert(key.into(), json!(value)); } } /// 取某个节点直接挂着的条目,按 `sort` 升序。 fn load_node_entries(conn: &Connection, node_key: &str) -> Result, 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 WHERE e.node_key = ?1 ORDER BY e.sort, e.key", ) .map_err(map_error)?; let entries = stmt .query_map([node_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::, _>>() .map_err(map_error)?; Ok(entries) }