//! 内容库的查询组装:把统一的 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::{Catalog, map_error}, error::StashError, }; /// 容器节点行:分区与分组共用 struct NodeRow { key: String, title: String, view_mode: Option, bar_color: Option, icon: Option, preview_limit: Option, empty_text: Option, show_in_sidebar: bool, allow_groups: bool, } /// 条目行:与 `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, } /// 读出整个内容目录。 pub fn load(conn: &Connection) -> Result { Ok(Catalog { categories: load_sections(conn)?, }) } /// 分组树节点(新视图用) struct TreeNode { key: String, parent: Option, title: String, view_mode: Option, 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, view_mode, 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)?, view_mode: row.get(3)?, icon: row.get(4)?, bar_color: row.get(5)?, preview_limit: row.get(6)?, sort: row.get(7)?, enabled: row.get(8)?, }) }) .map_err(map_error)? .collect::, _>>() .map_err(map_error)?; drop(stmt); let index: HashMap = nodes .iter() .enumerate() .map(|(idx, node)| (node.key.clone(), idx)) .collect(); // 子节点索引(按 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 mut formats: Vec = 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, Vec>, formats: &[String], tags: &HashMap>, idx: usize, ) -> Result { 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, tags)) .collect::>(); let child_values = children .get(&Some(node.key.clone())) .map(|list| { list.iter() .map(|child| render(conn, nodes, children, formats, tags, *child)) .collect::, _>>() }) .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, "enabled": node.enabled, "entries": items, "children": child_values, })) } let roots = children .get(&None) .map(|list| { list.iter() .map(|idx| render(conn, &nodes, &children, &formats, &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) } /// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。 fn load_sections(conn: &Connection) -> Result, StashError> { let mut stmt = conn .prepare( "SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups FROM node WHERE kind = 'section' ORDER BY sort, key", ) .map_err(map_error)?; let sections = stmt .query_map([], |row| { Ok(NodeRow { key: row.get(0)?, title: row.get(1)?, view_mode: row.get(2)?, bar_color: row.get(3)?, icon: row.get(4)?, preview_limit: row.get(5)?, empty_text: row.get(6)?, show_in_sidebar: row.get(7)?, allow_groups: row.get(8)?, }) }) .map_err(map_error)? .collect::, _>>() .map_err(map_error)?; // 先释放语句再发后续查询,避免同一连接上的借用冲突 drop(stmt); // 每个分区对应的首页卡片:展示哪个节点、几条 let blocks = load_blocks(conn)?; sections .iter() .map(|section| build_section(conn, section, blocks.get(§ion.key))) .collect() } /// 读出所有卡片配置,按推导出的分区业务键索引。 /// /// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。 /// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。 fn load_blocks(conn: &Connection) -> Result, StashError> { let mut stmt = conn .prepare( "SELECT COALESCE(p.key, n.key) AS section_key, b.node_key, COALESCE(b.title, n.title), b.preview_limit FROM section_block b JOIN node n ON n.key = b.node_key LEFT JOIN node p ON p.key = n.parent_key ORDER BY b.sort, b.node_key", ) .map_err(map_error)?; let rows = stmt .query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, )) }) .map_err(map_error)? .collect::, _>>() .map_err(map_error)?; let mut blocks = HashMap::new(); for (section_key, node_key, title, preview_limit) in rows { let mut block = Map::new(); block.insert("nodeKey".into(), json!(node_key)); block.insert("title".into(), json!(title)); if let Some(limit) = preview_limit { block.insert("previewLimit".into(), json!(limit)); } // 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点 blocks.entry(section_key).or_insert(Value::Object(block)); } Ok(blocks) } /// 按 `view_mode` 分派到不同的组装方式。 fn build_section( conn: &Connection, section: &NodeRow, block: Option<&Value>, ) -> Result { let view_mode = section .view_mode .as_deref() .ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?; let mut object = Map::new(); object.insert("id".into(), json!(section.key)); object.insert("kind".into(), json!(view_mode)); object.insert("title".into(), json!(section.title)); object.insert( "barColor".into(), json!(section.bar_color.clone().unwrap_or_default()), ); object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default())); object.insert("showInSidebar".into(), json!(section.show_in_sidebar)); object.insert("allowGroups".into(), json!(section.allow_groups)); // 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理 if let Some(limit) = section.preview_limit { object.insert("previewLimit".into(), json!(limit)); } if let Some(empty_text) = §ion.empty_text { object.insert("emptyText".into(), json!(empty_text)); } if let Some(block) = block { object.insert("block".into(), block.clone()); } match view_mode { "apps" | "posters" | "articles" => {} other => { return Err(StashError::Database(format!( "分区 {} 的 view_mode 非法: {other}", section.key ))); } } // 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺 let tags = load_tags(conn, §ion.key)?; if section.allow_groups { object.insert( "groups".into(), build_groups(conn, section, &tags)?, ); let direct = load_node_entries(conn, §ion.key)?; let items = direct .iter() .map(|entry| item_for(entry, &tags)) .collect::>(); object.insert("items".into(), json!(items)); } else { object.insert("groups".into(), json!([])); // 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容 let entries = load_section_entries(conn, §ion.key)?; let items = entries .iter() .map(|entry| item_for(entry, &tags)) .collect::>(); object.insert("items".into(), json!(items)); } Ok(Value::Object(object)) } /// 按条目自身的格式组装列表项。 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 } /// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。 fn build_groups( conn: &Connection, section: &NodeRow, tags: &HashMap>, ) -> Result { let mut stmt = conn .prepare( "SELECT key, title, show_in_sidebar FROM node WHERE kind = 'group' AND parent_key = ?1 ORDER BY sort, key", ) .map_err(map_error)?; let groups = stmt .query_map([§ion.key], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, bool>(2)?, )) }) .map_err(map_error)? .collect::, _>>() .map_err(map_error)?; drop(stmt); let mut rendered_groups = Vec::with_capacity(groups.len()); for (group_key, group_title, show_in_sidebar) in groups { let entries = load_node_entries(conn, &group_key)?; let items = entries .iter() .map(|entry| item_for(entry, tags)) .collect::>(); rendered_groups.push(json!({ // 对外用业务键,不用数字主键 "id": group_key, "name": group_title, "showInSidebar": show_in_sidebar, "items": items, })); } Ok(Value::Array(rendered_groups)) } /// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。 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` 升序。 /// /// 条目只存 `node_key`,所属分区由父节点推导: /// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。 /// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。 fn load_section_entries( conn: &Connection, section_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 JOIN node p ON p.key = e.node_key WHERE COALESCE(p.parent_key, p.key) = ?1 ORDER BY e.sort, e.key", ) .map_err(map_error)?; let entries = stmt .query_map([section_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) } 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) } /// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。 fn load_tags(conn: &Connection, section_key: &str) -> Result>, StashError> { let mut stmt = conn .prepare( "SELECT t.entry_key, t.label FROM entry_tag t JOIN entry e ON e.key = t.entry_key JOIN node p ON p.key = e.node_key WHERE COALESCE(p.parent_key, p.key) = ?1 ORDER BY t.entry_key, t.sort", ) .map_err(map_error)?; let rows = stmt .query_map([section_key], |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) }