- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入 - 分组管理移除格式字段/显示,根分组只作容器 - 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式 - 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板 - 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
621 lines
20 KiB
Rust
621 lines
20 KiB
Rust
//! 内容库的查询组装:把统一的 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<String>,
|
||
bar_color: Option<String>,
|
||
icon: Option<String>,
|
||
preview_limit: Option<i64>,
|
||
empty_text: Option<String>,
|
||
show_in_sidebar: bool,
|
||
allow_groups: bool,
|
||
}
|
||
|
||
/// 条目行:与 `entry` 表的列一一对应
|
||
struct EntryRow {
|
||
key: String,
|
||
format: String,
|
||
name: String,
|
||
description: Option<String>,
|
||
icon: Option<String>,
|
||
href: Option<String>,
|
||
cover: Option<String>,
|
||
banner: Option<String>,
|
||
badge: Option<String>,
|
||
comment: Option<String>,
|
||
summary: Option<String>,
|
||
body: Option<String>,
|
||
hot: bool,
|
||
views: i64,
|
||
likes: i64,
|
||
}
|
||
|
||
/// 读出整个内容目录。
|
||
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
||
Ok(Catalog {
|
||
categories: load_sections(conn)?,
|
||
})
|
||
}
|
||
|
||
/// 分组树节点(新视图用)
|
||
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,
|
||
enabled: bool,
|
||
}
|
||
|
||
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
|
||
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, 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::<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, 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,
|
||
"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::<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
|
||
.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::<Result<Vec<_>, _>>()
|
||
.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<HashMap<String, Value>, 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<i64>>(3)?,
|
||
))
|
||
})
|
||
.map_err(map_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.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<Value, StashError> {
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
object.insert("items".into(), json!(items));
|
||
}
|
||
|
||
Ok(Value::Object(object))
|
||
}
|
||
|
||
/// 按条目自身的格式组装列表项。
|
||
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> 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<String, Vec<String>>,
|
||
) -> Result<Value, StashError> {
|
||
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::<Result<Vec<_>, _>>()
|
||
.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::<Vec<_>>();
|
||
|
||
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<String>>) -> 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<String>>) -> 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<String, Value>, key: &str, value: &Option<String>) {
|
||
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<Vec<EntryRow>, 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::<Result<Vec<_>, _>>()
|
||
.map_err(map_error)?;
|
||
|
||
Ok(entries)
|
||
}
|
||
|
||
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, 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::<Result<Vec<_>, _>>()
|
||
.map_err(map_error)?;
|
||
|
||
Ok(entries)
|
||
}
|
||
|
||
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
|
||
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, 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::<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)
|
||
}
|