334 lines
11 KiB
Rust
334 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|||
|
|
use serde_json::Value;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct WorkspaceShellProjection {
|
||
|
|
pub schema: String,
|
||
|
|
pub workspace_id: String,
|
||
|
|
pub workspace_name: String,
|
||
|
|
pub active_page_id: Option<String>,
|
||
|
|
pub active_page_title: Option<String>,
|
||
|
|
pub starred_items: Vec<WorkspaceShellItem>,
|
||
|
|
pub my_page_items: Vec<WorkspaceShellItem>,
|
||
|
|
pub bottom_entries: Vec<WorkspaceShellEntry>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct WorkspaceShellItem {
|
||
|
|
pub id: String,
|
||
|
|
pub title: String,
|
||
|
|
pub icon: Option<String>,
|
||
|
|
pub href: String,
|
||
|
|
pub depth: u32,
|
||
|
|
pub active: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct WorkspaceShellEntry {
|
||
|
|
pub id: String,
|
||
|
|
pub label: String,
|
||
|
|
pub href: String,
|
||
|
|
pub icon: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn build_workspace_shell_projection(
|
||
|
|
dataset: &Value,
|
||
|
|
workspace_id: &str,
|
||
|
|
active_page_id: Option<&str>,
|
||
|
|
default_workspace_name: &str,
|
||
|
|
) -> WorkspaceShellProjection {
|
||
|
|
let active_page_id = active_page_id
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.map(ToOwned::to_owned);
|
||
|
|
let documents = dataset
|
||
|
|
.get("documents")
|
||
|
|
.and_then(Value::as_array)
|
||
|
|
.cloned()
|
||
|
|
.unwrap_or_default();
|
||
|
|
let workspace_name = resolve_workspace_name(dataset, workspace_id)
|
||
|
|
.unwrap_or_else(|| default_workspace_name.trim().to_string())
|
||
|
|
.if_empty_else(|| "个人空间".to_string());
|
||
|
|
|
||
|
|
let mut my_page_items = documents
|
||
|
|
.iter()
|
||
|
|
.filter_map(|document| document_to_item(document, workspace_id, None))
|
||
|
|
.collect::<Vec<_>>();
|
||
|
|
my_page_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||
|
|
|
||
|
|
let active_page_id = active_page_id
|
||
|
|
.or_else(|| {
|
||
|
|
dataset
|
||
|
|
.get("active_page_id")
|
||
|
|
.or_else(|| dataset.get("activePageId"))
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.map(ToOwned::to_owned)
|
||
|
|
})
|
||
|
|
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
|
||
|
|
|
||
|
|
for item in &mut my_page_items {
|
||
|
|
item.active = active_page_id.as_deref().is_some_and(|active_id| active_id == item.id);
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut starred_items = documents
|
||
|
|
.iter()
|
||
|
|
.filter(|document| {
|
||
|
|
document
|
||
|
|
.get("is_starred")
|
||
|
|
.or_else(|| document.get("isStarred"))
|
||
|
|
.and_then(Value::as_bool)
|
||
|
|
.unwrap_or(false)
|
||
|
|
})
|
||
|
|
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
|
||
|
|
.collect::<Vec<_>>();
|
||
|
|
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||
|
|
|
||
|
|
let active_page_title = active_page_id.as_deref().and_then(|active_id| {
|
||
|
|
my_page_items
|
||
|
|
.iter()
|
||
|
|
.find(|item| item.id == active_id)
|
||
|
|
.map(|item| item.title.clone())
|
||
|
|
});
|
||
|
|
|
||
|
|
WorkspaceShellProjection {
|
||
|
|
schema: "mnote.workspace_shell.v1".into(),
|
||
|
|
workspace_id: workspace_id.to_string(),
|
||
|
|
workspace_name,
|
||
|
|
active_page_id,
|
||
|
|
active_page_title,
|
||
|
|
starred_items,
|
||
|
|
my_page_items,
|
||
|
|
bottom_entries: vec![
|
||
|
|
WorkspaceShellEntry {
|
||
|
|
id: "trash".into(),
|
||
|
|
label: "垃圾箱".into(),
|
||
|
|
href: "/trash".into(),
|
||
|
|
icon: "🗑".into(),
|
||
|
|
},
|
||
|
|
WorkspaceShellEntry {
|
||
|
|
id: "templates".into(),
|
||
|
|
label: "模板中心".into(),
|
||
|
|
href: "/templates".into(),
|
||
|
|
icon: "📦".into(),
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option<String> {
|
||
|
|
dataset
|
||
|
|
.get("workspaces")
|
||
|
|
.and_then(Value::as_array)
|
||
|
|
.and_then(|workspaces| {
|
||
|
|
workspaces.iter().find_map(|workspace| {
|
||
|
|
let id = workspace
|
||
|
|
.get("id")
|
||
|
|
.or_else(|| workspace.get("workspace_id"))
|
||
|
|
.or_else(|| workspace.get("workspaceId"))
|
||
|
|
.and_then(Value::as_str)?;
|
||
|
|
if id != workspace_id {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
workspace
|
||
|
|
.get("name")
|
||
|
|
.or_else(|| workspace.get("title"))
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.map(ToOwned::to_owned)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn document_to_item(
|
||
|
|
document: &Value,
|
||
|
|
workspace_id: &str,
|
||
|
|
active_page_id: Option<&str>,
|
||
|
|
) -> Option<WorkspaceShellItem> {
|
||
|
|
let document_workspace_id = document
|
||
|
|
.get("workspace_id")
|
||
|
|
.or_else(|| document.get("workspaceId"))
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.unwrap_or(workspace_id);
|
||
|
|
if document_workspace_id != workspace_id {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
if document
|
||
|
|
.get("deleted_at")
|
||
|
|
.or_else(|| document.get("deletedAt"))
|
||
|
|
.is_some_and(|value| !value.is_null())
|
||
|
|
{
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let id = document
|
||
|
|
.get("id")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())?;
|
||
|
|
let title = document
|
||
|
|
.get("title")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.unwrap_or("无标题");
|
||
|
|
let depth = document
|
||
|
|
.get("depth")
|
||
|
|
.and_then(Value::as_u64)
|
||
|
|
.or_else(|| {
|
||
|
|
document
|
||
|
|
.get("parent_id")
|
||
|
|
.or_else(|| document.get("parentId"))
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(|_| 1)
|
||
|
|
})
|
||
|
|
.unwrap_or(0) as u32;
|
||
|
|
Some(WorkspaceShellItem {
|
||
|
|
id: id.to_string(),
|
||
|
|
title: title.to_string(),
|
||
|
|
icon: document
|
||
|
|
.get("icon")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.map(ToOwned::to_owned),
|
||
|
|
href: format!("/documents/{id}?workspaceId={workspace_id}"),
|
||
|
|
depth,
|
||
|
|
active: active_page_id.is_some_and(|active_id| active_id == id),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
trait StringEmptyExt {
|
||
|
|
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||
|
|
}
|
||
|
|
|
||
|
|
impl StringEmptyExt for String {
|
||
|
|
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||
|
|
if self.trim().is_empty() {
|
||
|
|
fallback()
|
||
|
|
} else {
|
||
|
|
self
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn workspace_shell_projection_contains_sidebar_sections_and_bottom_entries() {
|
||
|
|
let dataset = json!({
|
||
|
|
"active_workspace_id": "ws_demo",
|
||
|
|
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||
|
|
"documents": [
|
||
|
|
{ "id": "page_home", "workspace_id": "ws_demo", "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": true },
|
||
|
|
{ "id": "page_child", "workspace_id": "ws_demo", "title": "软件开发", "parent_id": "page_home", "sort_order": 1, "is_starred": false }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
let projection = build_workspace_shell_projection(
|
||
|
|
&dataset,
|
||
|
|
"ws_demo",
|
||
|
|
Some("page_home"),
|
||
|
|
"开发用户 的工作区",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert_eq!(projection.schema, "mnote.workspace_shell.v1");
|
||
|
|
assert_eq!(projection.workspace_id, "ws_demo");
|
||
|
|
assert_eq!(projection.workspace_name, "开发用户 的工作区");
|
||
|
|
assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
|
||
|
|
assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
|
||
|
|
assert_eq!(projection.starred_items.len(), 1);
|
||
|
|
assert_eq!(projection.starred_items[0].title, "个人");
|
||
|
|
assert_eq!(projection.my_page_items.len(), 2);
|
||
|
|
assert!(projection.my_page_items[0].active);
|
||
|
|
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "垃圾箱"));
|
||
|
|
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "模板中心"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
pub fn render_workspace_shell_sidebar_html(
|
||
|
|
projection: &WorkspaceShellProjection,
|
||
|
|
sidebar_tree_html: Option<&str>,
|
||
|
|
) -> String {
|
||
|
|
let starred_rows = if projection.starred_items.is_empty() {
|
||
|
|
String::new()
|
||
|
|
} else {
|
||
|
|
projection
|
||
|
|
.starred_items
|
||
|
|
.iter()
|
||
|
|
.map(render_item_row)
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join("")
|
||
|
|
};
|
||
|
|
let projected_my_pages = projection
|
||
|
|
.my_page_items
|
||
|
|
.iter()
|
||
|
|
.map(render_item_row)
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join("");
|
||
|
|
let tree_html = sidebar_tree_html
|
||
|
|
.map(str::trim)
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.map(|html| {
|
||
|
|
format!(
|
||
|
|
r#"<div class="sidebar-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree">{html}</div></div>"#
|
||
|
|
)
|
||
|
|
})
|
||
|
|
.unwrap_or_default();
|
||
|
|
let my_pages = if projected_my_pages.is_empty() {
|
||
|
|
tree_html
|
||
|
|
} else {
|
||
|
|
format!("{projected_my_pages}{tree_html}")
|
||
|
|
};
|
||
|
|
let bottom_entries = projection
|
||
|
|
.bottom_entries
|
||
|
|
.iter()
|
||
|
|
.map(|entry| {
|
||
|
|
format!(
|
||
|
|
r#"<a href="{}" class="wolai-footer-entry"><span>{}</span>{}</a>"#,
|
||
|
|
escape_html(&entry.href),
|
||
|
|
escape_html(&entry.icon),
|
||
|
|
escape_html(&entry.label),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join("");
|
||
|
|
|
||
|
|
format!(
|
||
|
|
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="我的页面"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><span class="wolai-section-add">+</span></div>{my_pages}</section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn render_item_row(item: &WorkspaceShellItem) -> String {
|
||
|
|
let active_class = if item.active { " wolai-active-row" } else { "" };
|
||
|
|
let depth_style = if item.depth == 0 {
|
||
|
|
String::new()
|
||
|
|
} else {
|
||
|
|
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
|
||
|
|
};
|
||
|
|
format!(
|
||
|
|
r#"<a class="wolai-page-row{active_class}" href="{}" data-node-id="{}"{depth_style}><span class="wolai-row-icon">{}</span>{}</a>"#,
|
||
|
|
escape_html(&item.href),
|
||
|
|
escape_html(&item.id),
|
||
|
|
escape_html(item.icon.as_deref().unwrap_or("▣")),
|
||
|
|
escape_html(&item.title),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn escape_html(value: &str) -> String {
|
||
|
|
value
|
||
|
|
.replace('&', "&")
|
||
|
|
.replace('<', "<")
|
||
|
|
.replace('>', ">")
|
||
|
|
.replace('"', """)
|
||
|
|
.replace('\'', "'")
|
||
|
|
}
|