收口本地工作区清理与资源投影

清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。

新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。

扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。

验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
This commit is contained in:
lix-2026
2026-05-20 10:43:38 +08:00
parent 90818cdf75
commit b4c8bcb647
73 changed files with 8073 additions and 8240 deletions
@@ -5,7 +5,6 @@ use serde_json::{json, Map, Value};
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
pub title: String,
pub mnote_id: Option<String>,
pub body: String,
}
@@ -37,6 +36,14 @@ enum MarkdownBlock {
alignments: Vec<TableAlignment>,
rows: Vec<MarkdownTableRow>,
},
Image {
alt: String,
source_path: String,
},
Mindmap {
name: String,
source_path: String,
},
Media {
name: String,
source_path: String,
@@ -71,18 +78,10 @@ struct MarkdownInlineStyles {
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (frontmatter, body) = split_frontmatter(markdown);
let title = frontmatter
.as_deref()
.and_then(|content| read_frontmatter_field(content, "title"))
.or_else(|| extract_first_h1_title(body))
.unwrap_or_else(|| file_stem_title(file_name));
let mnote_id = frontmatter
.as_deref()
.and_then(|content| read_frontmatter_field(content, "mnote_id"));
let (_, body) = split_frontmatter(markdown);
let title = file_stem_title(file_name);
ParsedLocalMarkdownPage {
title,
mnote_id,
body: body.to_string(),
}
}
@@ -97,7 +96,12 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
.unwrap_or(trimmed)
.strip_prefix('[')?;
let (label, rest) = value.split_once("](")?;
let target = rest.strip_suffix(')')?.trim();
let raw_target = rest.strip_suffix(')')?.trim();
let target = raw_target
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_target)
.trim();
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
@@ -179,6 +183,14 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}
fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
if let Some((alt, source_path)) = paragraph_image(node) {
blocks.push(MarkdownBlock::Image { alt, source_path });
return;
}
if let Some((name, source_path)) = paragraph_mindmap(node) {
blocks.push(MarkdownBlock::Mindmap { name, source_path });
return;
}
if let Some((name, source_path)) = paragraph_attachment_media(node) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
@@ -350,6 +362,28 @@ fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, Stri
link_attachment_media(first)
}
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_mindmap(first)
}
fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
let NodeValue::Image(link) = &first.data.borrow().value else {
return None;
};
let alt = collect_plain_text(first).trim().to_string();
Some((alt, link.url.clone()))
}
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
@@ -377,6 +411,33 @@ fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)>
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
}
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
let target = link.url.trim();
let file_name = std::path::Path::new(target)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(target)
.trim();
let lower = file_name.to_ascii_lowercase();
let is_mindmap = lower.ends_with(".mindmap.json")
|| (file_name.starts_with("思维导图") && lower.ends_with(".json"));
if !is_mindmap {
return None;
}
let name = collect_plain_text(node).trim().to_string();
Some((
if name.is_empty() {
"思维导图".to_string()
} else {
name
},
target.to_string(),
))
}
fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value {
Value::Array(
document
@@ -438,6 +499,29 @@ fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value {
MarkdownBlock::Table { alignments, rows } => {
table_block_to_json(alignments, rows, block_number)
}
MarkdownBlock::Image { alt, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "image",
"props": {
"src": source_path,
"alt": alt,
"title": alt,
},
"content": [],
"children": [],
}),
MarkdownBlock::Mindmap { name, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "mindmap",
"props": {
"name": name,
"sourcePath": source_path,
"mindmapId": source_path,
"rootNodeId": "root",
},
"content": [],
"children": [],
}),
MarkdownBlock::Media { name, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "media",
@@ -634,32 +718,6 @@ pub(crate) fn split_frontmatter(markdown: &str) -> (Option<String>, &str) {
(None, normalized)
}
fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option<String> {
frontmatter.lines().find_map(|line| {
let (candidate_key, value) = line.split_once(':')?;
if candidate_key.trim() != key {
return None;
}
let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn extract_first_h1_title(body: &str) -> Option<String> {
body.lines().find_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("# ")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
pub(crate) fn file_stem_title(file_name: &str) -> String {
std::path::Path::new(file_name)
.file_stem()
@@ -669,3 +727,21 @@ pub(crate) fn file_stem_title(file_name: &str) -> String {
.unwrap_or(file_name)
.to_string()
}
#[cfg(test)]
mod tests {
use super::markdown_to_blocks;
#[test]
fn markdown_image_parses_as_image_block() {
let blocks = markdown_to_blocks("![示例图片](assets/photo.jpg)\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("image"));
assert_eq!(first["props"]["src"].as_str(), Some("assets/photo.jpg"));
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
}