收口本地工作区清理与资源投影
清理历史 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:
@@ -188,3 +188,645 @@ pub struct EmbedBlock {
|
||||
pub struct DeleteBlock {
|
||||
pub block_id: String,
|
||||
}
|
||||
|
||||
// ── CommandContext / when clause ────────────────────────────────────
|
||||
|
||||
/// CommandContext — 命令执行上下文的 key-value 集合。
|
||||
///
|
||||
/// 对应 VS Code 的 context key 概念。每个 key 表示当前 UI/workspace/editor 的状态片段。
|
||||
/// 菜单项、快捷键、按钮的 enablement 通过 `when` 表达式对 context 求值得到。
|
||||
///
|
||||
/// 第一阶段支持 key 列表:
|
||||
/// - `workspace.sourceKind` — 工作区类型 ("local_folder" | "convex" | ...)
|
||||
/// - `workspace.readonly` — bool,工作区是否只读
|
||||
/// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none")
|
||||
/// - `tree.selectionCount` — 当前选中行数 (i64)
|
||||
/// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...)
|
||||
/// - `editor.dirty` — bool,当前编辑器是否有未保存修改
|
||||
/// - `editor.hasSelection` — bool,编辑器是否有文本选区
|
||||
/// - `ai.canWrite` — bool,当前会话是否允许 AI 写入
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandContext {
|
||||
/// 工作区来源类型
|
||||
pub workspace_source_kind: Option<String>,
|
||||
/// 工作区是否只读
|
||||
pub workspace_readonly: bool,
|
||||
/// 树焦点类型
|
||||
pub tree_focus_kind: Option<String>,
|
||||
/// 树选择计数
|
||||
pub tree_selection_count: i64,
|
||||
/// 选择资源的 kind
|
||||
pub tree_selection_resource_kind: Option<String>,
|
||||
/// 编辑器是否 dirty
|
||||
pub editor_dirty: bool,
|
||||
/// 编辑器是否有选区
|
||||
pub editor_has_selection: bool,
|
||||
/// AI 是否可写
|
||||
pub ai_can_write: bool,
|
||||
}
|
||||
|
||||
impl CommandContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
workspace_source_kind: None,
|
||||
workspace_readonly: false,
|
||||
tree_focus_kind: None,
|
||||
tree_selection_count: 0,
|
||||
tree_selection_resource_kind: None,
|
||||
editor_dirty: false,
|
||||
editor_has_selection: false,
|
||||
ai_can_write: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取命名 context key 的值(用于 when 表达式求值)。
|
||||
pub fn get(&self, key: &str) -> Option<CommandContextValue> {
|
||||
match key {
|
||||
"workspace.sourceKind" => self
|
||||
.workspace_source_kind
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"workspace.readonly" => Some(CommandContextValue::Bool(self.workspace_readonly)),
|
||||
"tree.focusKind" => self
|
||||
.tree_focus_kind
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"tree.selectionCount" => Some(CommandContextValue::Number(self.tree_selection_count)),
|
||||
"tree.selectionResourceKind" => self
|
||||
.tree_selection_resource_kind
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"editor.dirty" => Some(CommandContextValue::Bool(self.editor_dirty)),
|
||||
"editor.hasSelection" => Some(CommandContextValue::Bool(self.editor_has_selection)),
|
||||
"ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 workspace 只读。
|
||||
pub fn with_workspace_readonly(mut self, readonly: bool) -> Self {
|
||||
self.workspace_readonly = readonly;
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 workspace source kind。
|
||||
pub fn with_workspace_source_kind(mut self, kind: &str) -> Self {
|
||||
self.workspace_source_kind = Some(kind.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 tree focus kind。
|
||||
pub fn with_tree_focus_kind(mut self, kind: &str) -> Self {
|
||||
self.tree_focus_kind = Some(kind.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 tree selection count。
|
||||
pub fn with_tree_selection_count(mut self, count: i64) -> Self {
|
||||
self.tree_selection_count = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 selection resource kind。
|
||||
pub fn with_tree_selection_resource_kind(mut self, kind: &str) -> Self {
|
||||
self.tree_selection_resource_kind = Some(kind.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 editor dirty。
|
||||
pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
|
||||
self.editor_dirty = dirty;
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 editor has selection。
|
||||
pub fn with_editor_has_selection(mut self, has_selection: bool) -> Self {
|
||||
self.editor_has_selection = has_selection;
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 ai can write。
|
||||
pub fn with_ai_can_write(mut self, can_write: bool) -> Self {
|
||||
self.ai_can_write = can_write;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令上下文中的值类型。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CommandContextValue {
|
||||
Bool(bool),
|
||||
Number(i64),
|
||||
String(String),
|
||||
}
|
||||
|
||||
impl CommandContextValue {
|
||||
fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
CommandContextValue::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_string(&self) -> Option<&str> {
|
||||
match self {
|
||||
CommandContextValue::String(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WhenExprToken — when 表达式的最小 token 类型。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WhenExprToken {
|
||||
/// `key`
|
||||
Key(String),
|
||||
/// `!key`
|
||||
NotKey(String),
|
||||
/// `key == value`
|
||||
Equal(String, String),
|
||||
/// `key != value`
|
||||
NotEqual(String, String),
|
||||
/// `&&`
|
||||
And,
|
||||
/// `||`
|
||||
Or,
|
||||
/// `(` ... `)`
|
||||
LParen,
|
||||
RParen,
|
||||
}
|
||||
|
||||
/// WhenExpr — when 表达式的 AST 节点。
|
||||
/// 支持:`key`、`!key`、`key == value`、`key != value`、`&&`、`||`、括号分组。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WhenExprNode {
|
||||
Key(String),
|
||||
NotKey(String),
|
||||
Equal(String, String),
|
||||
NotEqual(String, String),
|
||||
And(Box<WhenExprNode>, Box<WhenExprNode>),
|
||||
Or(Box<WhenExprNode>, Box<WhenExprNode>),
|
||||
}
|
||||
|
||||
/// 简单的递归下降 when 表达式解析器。
|
||||
/// 语法:
|
||||
/// expr = or_expr
|
||||
/// or_expr = and_expr ("||" and_expr)*
|
||||
/// and_expr = primary ("&&" primary)*
|
||||
/// primary = "(" expr ")" | "!" key | "key == value" | "key != value" | key
|
||||
pub fn parse_when_expr(input: &str) -> Result<WhenExprNode, String> {
|
||||
let tokens = tokenize(input)?;
|
||||
let (node, rest) = parse_or(&tokens)?;
|
||||
if !rest.is_empty() {
|
||||
return Err(format!("when 表达式末尾有多余 token: {:?}", rest));
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn tokenize(input: &str) -> Result<Vec<WhenExprToken>, String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_whitespace() {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'(' => {
|
||||
chars.next();
|
||||
tokens.push(WhenExprToken::LParen);
|
||||
}
|
||||
')' => {
|
||||
chars.next();
|
||||
tokens.push(WhenExprToken::RParen);
|
||||
}
|
||||
'!' => {
|
||||
chars.next();
|
||||
// ! 后面跟 key(不能是 "(")
|
||||
let key = read_identifier(&mut chars)?;
|
||||
tokens.push(WhenExprToken::NotKey(key));
|
||||
}
|
||||
'|' => {
|
||||
chars.next();
|
||||
if chars.next() == Some('|') {
|
||||
tokens.push(WhenExprToken::Or);
|
||||
} else {
|
||||
return Err("期望 || 运算符,但只看到一个 |".to_string());
|
||||
}
|
||||
}
|
||||
'&' => {
|
||||
chars.next();
|
||||
if chars.next() == Some('&') {
|
||||
tokens.push(WhenExprToken::And);
|
||||
} else {
|
||||
return Err("期望 && 运算符,但只看到一个 &".to_string());
|
||||
}
|
||||
}
|
||||
'=' => {
|
||||
return Err("意外的 = 符号,应为 ==".to_string());
|
||||
}
|
||||
_ if is_ident_start(ch) => {
|
||||
let key = read_identifier(&mut chars)?;
|
||||
// 检查后面是否跟着 == 或 !=
|
||||
let mut next_chars = chars.clone();
|
||||
let after_ws = next_chars.find(|c| !c.is_whitespace());
|
||||
let is_comparison = matches!(after_ws, Some('=') | Some('!'));
|
||||
if is_comparison {
|
||||
// 跳过空白
|
||||
while chars.peek().map_or(false, |c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
let op_start = chars.next(); // = or !
|
||||
let op_next = chars.next(); // =
|
||||
if op_start == Some('=') && op_next == Some('=') {
|
||||
// ==
|
||||
while chars.peek().map_or(false, |c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
let value = read_value(&mut chars)?;
|
||||
tokens.push(WhenExprToken::Equal(key, value));
|
||||
} else if op_start == Some('!') && op_next == Some('=') {
|
||||
// !=
|
||||
while chars.peek().map_or(false, |c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
let value = read_value(&mut chars)?;
|
||||
tokens.push(WhenExprToken::NotEqual(key, value));
|
||||
} else {
|
||||
return Err(format!("key '{}' 后面的运算符需为 == 或 !=", key));
|
||||
}
|
||||
} else {
|
||||
tokens.push(WhenExprToken::Key(key));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("意外的字符: '{}'", ch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn read_identifier(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, String> {
|
||||
let mut ident = String::new();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_alphanumeric() || ch == '_' || ch == '.' {
|
||||
ident.push(ch);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ident.is_empty() {
|
||||
return Err("期望标识符".to_string());
|
||||
}
|
||||
Ok(ident)
|
||||
}
|
||||
|
||||
fn read_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, String> {
|
||||
let mut value = String::new();
|
||||
// 跳过空白 (已经在调用前跳过了)
|
||||
// 检查是否有引号
|
||||
if chars.peek() == Some(&'"') || chars.peek() == Some(&'\'') {
|
||||
let quote = chars.next().unwrap();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch == quote {
|
||||
chars.next();
|
||||
return Ok(value);
|
||||
}
|
||||
value.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
return Err("未闭合的引号".to_string());
|
||||
}
|
||||
// 无引号值:读取到下一个空白或运算符为止
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_whitespace() || ch == ')' || ch == '|' || ch == '&' {
|
||||
break;
|
||||
}
|
||||
value.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
if value.is_empty() {
|
||||
return Err("期望值".to_string());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn is_ident_start(ch: char) -> bool {
|
||||
ch.is_ascii_alphabetic() || ch == '_'
|
||||
}
|
||||
|
||||
fn parse_or<'a>(
|
||||
tokens: &'a [WhenExprToken],
|
||||
) -> Result<(WhenExprNode, &'a [WhenExprToken]), String> {
|
||||
let (mut left, mut rest) = parse_and(tokens)?;
|
||||
while let Some(WhenExprToken::Or) = rest.first() {
|
||||
rest = &rest[1..];
|
||||
let (right, new_rest) = parse_and(rest)?;
|
||||
left = WhenExprNode::Or(Box::new(left), Box::new(right));
|
||||
rest = new_rest;
|
||||
}
|
||||
Ok((left, rest))
|
||||
}
|
||||
|
||||
fn parse_and<'a>(
|
||||
tokens: &'a [WhenExprToken],
|
||||
) -> Result<(WhenExprNode, &'a [WhenExprToken]), String> {
|
||||
let (mut left, mut rest) = parse_primary(tokens)?;
|
||||
while let Some(WhenExprToken::And) = rest.first() {
|
||||
rest = &rest[1..];
|
||||
let (right, new_rest) = parse_primary(rest)?;
|
||||
left = WhenExprNode::And(Box::new(left), Box::new(right));
|
||||
rest = new_rest;
|
||||
}
|
||||
Ok((left, rest))
|
||||
}
|
||||
|
||||
fn parse_primary<'a>(
|
||||
tokens: &'a [WhenExprToken],
|
||||
) -> Result<(WhenExprNode, &'a [WhenExprToken]), String> {
|
||||
if let Some(token) = tokens.first() {
|
||||
match token {
|
||||
WhenExprToken::Key(key) => Ok((WhenExprNode::Key(key.clone()), &tokens[1..])),
|
||||
WhenExprToken::NotKey(key) => Ok((WhenExprNode::NotKey(key.clone()), &tokens[1..])),
|
||||
WhenExprToken::Equal(key, value) => Ok((
|
||||
WhenExprNode::Equal(key.clone(), value.clone()),
|
||||
&tokens[1..],
|
||||
)),
|
||||
WhenExprToken::NotEqual(key, value) => Ok((
|
||||
WhenExprNode::NotEqual(key.clone(), value.clone()),
|
||||
&tokens[1..],
|
||||
)),
|
||||
WhenExprToken::LParen => {
|
||||
let (node, rest) = parse_or(&tokens[1..])?;
|
||||
if let Some(WhenExprToken::RParen) = rest.first() {
|
||||
Ok((node, &rest[1..]))
|
||||
} else {
|
||||
Err("缺少闭合的 ')'".to_string())
|
||||
}
|
||||
}
|
||||
_ => Err(format!("期望表达式,得到 {:?}", token)),
|
||||
}
|
||||
} else {
|
||||
Err("期望表达式,但没有更多 token".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 对 `CommandContext` 求值一个 when 表达式。
|
||||
pub fn evaluate_when(ctx: &CommandContext, expr: &WhenExprNode) -> bool {
|
||||
match expr {
|
||||
WhenExprNode::Key(key) => ctx.get(key).map_or(false, |v| match v {
|
||||
CommandContextValue::Bool(b) => b,
|
||||
CommandContextValue::Number(n) => n != 0,
|
||||
CommandContextValue::String(s) => !s.is_empty(),
|
||||
}),
|
||||
WhenExprNode::NotKey(key) => !ctx.get(key).map_or(false, |v| match v {
|
||||
CommandContextValue::Bool(b) => b,
|
||||
CommandContextValue::Number(n) => n != 0,
|
||||
CommandContextValue::String(s) => !s.is_empty(),
|
||||
}),
|
||||
WhenExprNode::Equal(key, expected) => ctx.get(key).map_or(false, |v| {
|
||||
v.as_string().map_or(false, |s| s == expected)
|
||||
|| expected
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.is_some_and(|expected_number| {
|
||||
matches!(v, CommandContextValue::Number(n) if n == expected_number)
|
||||
})
|
||||
|| v.as_bool().map_or(false, |b| {
|
||||
expected == "true" && b || expected == "false" && !b
|
||||
})
|
||||
}),
|
||||
WhenExprNode::NotEqual(key, expected) => ctx.get(key).map_or(true, |v| {
|
||||
v.as_string().map_or(true, |s| s != expected)
|
||||
&& expected
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.is_none_or(|expected_number| {
|
||||
!matches!(v, CommandContextValue::Number(n) if n == expected_number)
|
||||
})
|
||||
&& v.as_bool().map_or(true, |b| {
|
||||
!(expected == "true" && b || expected == "false" && !b)
|
||||
})
|
||||
}),
|
||||
WhenExprNode::And(left, right) => evaluate_when(ctx, left) && evaluate_when(ctx, right),
|
||||
WhenExprNode::Or(left, right) => evaluate_when(ctx, left) || evaluate_when(ctx, right),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析并求值一条 when 表达式字符串。
|
||||
pub fn check_when(ctx: &CommandContext, when_expr: &str) -> Result<bool, String> {
|
||||
let expr = parse_when_expr(when_expr)?;
|
||||
Ok(evaluate_when(ctx, &expr))
|
||||
}
|
||||
|
||||
/// 检查一个命令在指定上下文中是否应 enabled。
|
||||
/// 如果 when 表达式为 None 或空,默认返回 true。
|
||||
pub fn is_command_enabled(ctx: &CommandContext, when: Option<&str>) -> bool {
|
||||
match when {
|
||||
None | Some("") => true,
|
||||
Some(expr) => check_when(ctx, expr).unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_context_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_context_when_clause() {
|
||||
let ctx = CommandContext::new()
|
||||
.with_workspace_readonly(false)
|
||||
.with_workspace_source_kind("local_folder")
|
||||
.with_tree_focus_kind("file_tree")
|
||||
.with_tree_selection_count(1)
|
||||
.with_tree_selection_resource_kind("page")
|
||||
.with_editor_dirty(false)
|
||||
.with_ai_can_write(true);
|
||||
|
||||
// 基础 key 表达式(bool)
|
||||
assert!(check_when(&ctx, "ai.canWrite").unwrap());
|
||||
assert!(!check_when(&ctx, "editor.dirty").unwrap());
|
||||
assert!(!check_when(&ctx, "workspace.readonly").unwrap());
|
||||
|
||||
// !key
|
||||
assert!(check_when(&ctx, "!editor.dirty").unwrap());
|
||||
assert!(!check_when(&ctx, "!ai.canWrite").unwrap());
|
||||
|
||||
// key == value
|
||||
assert!(check_when(&ctx, "tree.focusKind == file_tree").unwrap());
|
||||
assert!(!check_when(&ctx, "tree.focusKind == page_tree").unwrap());
|
||||
|
||||
// key != value
|
||||
assert!(check_when(&ctx, "tree.focusKind != page_tree").unwrap());
|
||||
assert!(!check_when(&ctx, "tree.focusKind != file_tree").unwrap());
|
||||
|
||||
// && 组合
|
||||
assert!(check_when(&ctx, "ai.canWrite && !workspace.readonly").unwrap());
|
||||
assert!(!check_when(&ctx, "ai.canWrite && workspace.readonly").unwrap());
|
||||
assert!(!check_when(&ctx, "editor.dirty && ai.canWrite").unwrap());
|
||||
|
||||
// || 组合
|
||||
assert!(check_when(&ctx, "editor.dirty || ai.canWrite").unwrap());
|
||||
assert!(!check_when(&ctx, "editor.dirty || workspace.readonly").unwrap());
|
||||
|
||||
// 括号分组
|
||||
assert!(check_when(&ctx, "(ai.canWrite && !workspace.readonly) || editor.dirty").unwrap());
|
||||
assert!(!check_when(&ctx, "editor.dirty && (ai.canWrite || workspace.readonly)").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_disables_write_commands_for_readonly_workspace() {
|
||||
let write_commands = vec![
|
||||
(
|
||||
"tree.node.create",
|
||||
Some("!workspace.readonly && ai.canWrite"),
|
||||
),
|
||||
("tree.node.rename", Some("!workspace.readonly")),
|
||||
("tree.node.delete", Some("!workspace.readonly")),
|
||||
("tree.node.move", Some("!workspace.readonly")),
|
||||
];
|
||||
|
||||
let ctx_readonly = CommandContext::new()
|
||||
.with_workspace_readonly(true)
|
||||
.with_ai_can_write(true);
|
||||
|
||||
let ctx_writable = CommandContext::new()
|
||||
.with_workspace_readonly(false)
|
||||
.with_ai_can_write(true);
|
||||
|
||||
for (_name, when) in &write_commands {
|
||||
// 只读上下文应禁用所有写命令
|
||||
assert!(
|
||||
!is_command_enabled(&ctx_readonly, *when),
|
||||
"只读上下文不应启用命令 {:?}",
|
||||
_name
|
||||
);
|
||||
// 可写上下文应启用
|
||||
assert!(
|
||||
is_command_enabled(&ctx_writable, *when),
|
||||
"可写上下文应启用命令 {:?}",
|
||||
_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_filters_resource_commands_by_kind() {
|
||||
let folder_commands = vec![
|
||||
("tree.node.create", Some("tree.focusKind == file_tree")),
|
||||
(
|
||||
"tree.folder.rename",
|
||||
Some("tree.selectionResourceKind == folder"),
|
||||
),
|
||||
];
|
||||
let page_commands = vec![
|
||||
("page.open", Some("tree.selectionResourceKind == page")),
|
||||
("page.delete", Some("tree.selectionResourceKind == page")),
|
||||
];
|
||||
let mindmap_commands = vec![(
|
||||
"mindmap.open",
|
||||
Some("tree.selectionResourceKind == mindmap"),
|
||||
)];
|
||||
|
||||
let ctx_folder = CommandContext::new()
|
||||
.with_tree_focus_kind("file_tree")
|
||||
.with_tree_selection_resource_kind("folder")
|
||||
.with_tree_selection_count(1);
|
||||
|
||||
let ctx_page = CommandContext::new()
|
||||
.with_tree_focus_kind("file_tree")
|
||||
.with_tree_selection_resource_kind("page")
|
||||
.with_tree_selection_count(1);
|
||||
|
||||
let ctx_mindmap = CommandContext::new()
|
||||
.with_tree_focus_kind("file_tree")
|
||||
.with_tree_selection_resource_kind("mindmap")
|
||||
.with_tree_selection_count(1);
|
||||
|
||||
// folder 上下文
|
||||
for (_name, when) in &folder_commands {
|
||||
assert!(
|
||||
is_command_enabled(&ctx_folder, *when),
|
||||
"folder ctx 应启用命令 {:?}",
|
||||
_name
|
||||
);
|
||||
}
|
||||
for (_name, when) in &page_commands {
|
||||
assert!(
|
||||
!is_command_enabled(&ctx_folder, *when),
|
||||
"folder ctx 不应启用 page 命令 {:?}",
|
||||
_name
|
||||
);
|
||||
}
|
||||
|
||||
// page 上下文
|
||||
for (_name, when) in &page_commands {
|
||||
assert!(
|
||||
is_command_enabled(&ctx_page, *when),
|
||||
"page ctx 应启用 page 命令 {:?}",
|
||||
_name
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!is_command_enabled(&ctx_page, Some("tree.selectionResourceKind == mindmap")),
|
||||
"page ctx 不应启用 mindmap 命令"
|
||||
);
|
||||
|
||||
// mindmap 上下文
|
||||
for (_name, when) in &mindmap_commands {
|
||||
assert!(
|
||||
is_command_enabled(&ctx_mindmap, *when),
|
||||
"mindmap ctx 应启用 mindmap 命令 {:?}",
|
||||
_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_default_is_allow() {
|
||||
let ctx = CommandContext::new();
|
||||
// 没有 when 表达式 → true
|
||||
assert!(is_command_enabled(&ctx, None));
|
||||
assert!(is_command_enabled(&ctx, Some("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_number_comparison() {
|
||||
let ctx = CommandContext::new().with_tree_selection_count(3);
|
||||
|
||||
// 数字值在 bool 语境下:非零为 true
|
||||
assert!(check_when(&ctx, "tree.selectionCount").unwrap());
|
||||
assert!(check_when(&ctx, "tree.selectionCount == 3").unwrap());
|
||||
assert!(check_when(&ctx, "tree.selectionCount != 1").unwrap());
|
||||
assert!(!check_when(&ctx, "tree.selectionCount == 1").unwrap());
|
||||
let ctx_zero = CommandContext::new().with_tree_selection_count(0);
|
||||
assert!(!check_when(&ctx_zero, "tree.selectionCount").unwrap());
|
||||
assert!(check_when(&ctx_zero, "tree.selectionCount == 0").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_parse_error_returns_default() {
|
||||
let ctx = CommandContext::new();
|
||||
// 解析错误 → is_command_enabled 返回 true(默认允许)
|
||||
assert!(is_command_enabled(&ctx, Some("invalid syntax &&&")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_ai_can_write_gates_ai_actions() {
|
||||
let ctx_can_write = CommandContext::new().with_ai_can_write(true);
|
||||
let ctx_readonly = CommandContext::new().with_ai_can_write(false);
|
||||
|
||||
assert!(is_command_enabled(&ctx_can_write, Some("ai.canWrite")));
|
||||
assert!(!is_command_enabled(&ctx_readonly, Some("ai.canWrite")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,12 @@ pub use ai::{
|
||||
AiShareContext, AiStructuredWriteKind, AiStructuredWriteResult, AiToolCall,
|
||||
};
|
||||
pub use command::{
|
||||
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
|
||||
MoveDocumentPage, PatchBlock, PatchPageBlock, PutMindmap, ReplaceMediaAssetStorage,
|
||||
RestoreDocumentPage, SavePageContent, UpdateBlock, UpdatePageOptions, UpdatePageStats,
|
||||
UpdatePageTitle,
|
||||
check_when, evaluate_when, is_command_enabled, parse_when_expr, CommandContext,
|
||||
CommandContextValue, CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage,
|
||||
CreateWorkspace, DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock,
|
||||
InsertBlock, MoveBlock, MoveDocumentPage, PatchBlock, PatchPageBlock, PutMindmap,
|
||||
ReplaceMediaAssetStorage, RestoreDocumentPage, SavePageContent, UpdateBlock, UpdatePageOptions,
|
||||
UpdatePageStats, UpdatePageTitle, WhenExprNode, WhenExprToken,
|
||||
};
|
||||
pub use common::{
|
||||
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
||||
|
||||
@@ -27,4 +27,4 @@ tower = "0.5"
|
||||
base64 = "0.22"
|
||||
comrak = { version = "0.52", default-features = false }
|
||||
notify = "8.2.0"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::acp_runtime::AcpRuntimeManager;
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use crate::editor_actor::EditorRuntimeActor;
|
||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||
use crate::middleware::request_context::inject_request_context;
|
||||
@@ -144,6 +145,7 @@ pub struct AppState {
|
||||
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub acp_runtime: Arc<AcpRuntimeManager>,
|
||||
pub buffer_store: BufferStore,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -152,13 +154,15 @@ impl AppState {
|
||||
let (stream_delta_tx, _) = broadcast::channel(256);
|
||||
let actor = EditorRuntimeActor::new();
|
||||
actor.set_block_delta_tx(block_delta_tx.clone());
|
||||
let buffer_store = BufferStore::new();
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(buffer_store.clone()),
|
||||
editor_actor: actor,
|
||||
block_delta_tx,
|
||||
stream_delta_tx,
|
||||
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
||||
buffer_store,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ impl RequestContext {
|
||||
cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()),
|
||||
actor_id: header_value(headers, HEADER_ACTOR_ID)
|
||||
.or_else(|| cookie_value(headers, COOKIE_ACTOR_ID))
|
||||
.and_then(|value| stable_actor_id(&value))
|
||||
.unwrap_or_else(|| "anonymous".into()),
|
||||
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
|
||||
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
|
||||
@@ -117,6 +118,22 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stable_actor_id(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stable = trimmed
|
||||
.split_once('|')
|
||||
.map(|(actor_id, _)| actor_id.trim())
|
||||
.unwrap_or(trimmed);
|
||||
if stable.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(stable.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn header_or_generated(headers: &HeaderMap, key: &str, prefix: &str) -> String {
|
||||
header_value(headers, key).unwrap_or_else(|| generate_id(prefix))
|
||||
}
|
||||
@@ -181,6 +198,23 @@ mod tests {
|
||||
assert_eq!(context.auth.actor_id, "user_demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_context_normalizes_pipe_separated_actor_id() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HEADER_ACTOR_ID,
|
||||
HeaderValue::from_static("user_demo|session_123"),
|
||||
);
|
||||
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/".parse::<Uri>().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
assert_eq!(context.auth.actor_id, "user_demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_context_falls_back_to_actor_cookies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -198,6 +232,26 @@ mod tests {
|
||||
assert_eq!(context.auth.actor_id, "user_cookie");
|
||||
assert_eq!(context.auth.actor_type, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_context_normalizes_pipe_separated_actor_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::COOKIE,
|
||||
HeaderValue::from_static(
|
||||
"mnote_actor_id=user_cookie|session_abc; mnote_actor_type=user",
|
||||
),
|
||||
);
|
||||
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/".parse::<Uri>().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
assert_eq!(context.auth.actor_id, "user_cookie");
|
||||
assert_eq!(context.auth.actor_type, "user");
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! BufferStore — 运行时 DocumentBuffer 管理器
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 持有所有已打开文档的 `DocumentBuffer` 实例
|
||||
//! - 以 `ObjectWorkspacePath` 的稳定复合身份作为 buffer key
|
||||
//! - tiptap 保存链、AI 写入、外部 watcher 均通过此模块仲裁状态
|
||||
//!
|
||||
//! 不是持久化结构,纯内存运行时。
|
||||
|
||||
use core_protocol::{
|
||||
DocBufferDirtyState, DocumentBuffer, KernelObjectIdentity, KernelObjectKind,
|
||||
ObjectWorkspacePath, WorkspaceSourceKind,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// BufferKey — 从 ObjectWorkspacePath 导出的稳定复合 key。
|
||||
///
|
||||
/// 由 workspace_id + source_kind + root_uri + relative_path + object_identity.document_id 组成。
|
||||
/// 同一 .md 文件由 tiptap、AI、外部 watcher 访问时落到同一 key。
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub struct BufferKey {
|
||||
pub workspace_id: String,
|
||||
pub source_kind: String,
|
||||
pub root_uri: String,
|
||||
pub relative_path: String,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BufferKey {
|
||||
/// 从 ObjectWorkspacePath 构建 BufferKey。
|
||||
pub fn from_workspace_path(path: &ObjectWorkspacePath) -> Self {
|
||||
Self {
|
||||
workspace_id: path.workspace_id.clone(),
|
||||
source_kind: format!("{:?}", path.source_kind),
|
||||
root_uri: path.root_uri.clone(),
|
||||
relative_path: path.relative_path.clone(),
|
||||
document_id: path.object_identity.document_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从文档基本属性构建 BufferKey。
|
||||
pub fn from_parts(
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
document_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
source_kind: source_kind.to_string(),
|
||||
root_uri: root_uri.to_string(),
|
||||
relative_path: relative_path.to_string(),
|
||||
document_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BufferStore — 运行时文档缓冲区管理器。
|
||||
///
|
||||
/// 每个 `AppState` 有一个实例,所有文档打开/保存/冲突检测共享同一状态模型。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BufferStore {
|
||||
inner: Arc<RwLock<BufferStoreInner>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BufferStoreInner {
|
||||
buffers: HashMap<BufferKey, DocumentBuffer>,
|
||||
}
|
||||
|
||||
impl BufferStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(BufferStoreInner {
|
||||
buffers: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或创建 buffer。
|
||||
///
|
||||
/// 如果该 key 已有 buffer,直接返回。否则用 `ObjectWorkspacePath` 创建一个 Clean buffer。
|
||||
pub fn get_or_create(&self, path: &ObjectWorkspacePath) -> DocumentBuffer {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get(&key) {
|
||||
return buf.clone();
|
||||
}
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
let buf = DocumentBuffer {
|
||||
workspace_path: path.clone(),
|
||||
file_version: None,
|
||||
base_content_hash: None,
|
||||
current_content_hash: None,
|
||||
dirty_state: DocBufferDirtyState::Clean,
|
||||
last_loaded_at: Some(now_ms),
|
||||
last_saved_at: Some(now_ms),
|
||||
external_actor: None,
|
||||
};
|
||||
inner.buffers.insert(key, buf.clone());
|
||||
buf
|
||||
}
|
||||
|
||||
/// 按 key 获取 buffer(不创建)。
|
||||
pub fn get(&self, key: &BufferKey) -> Option<DocumentBuffer> {
|
||||
let inner = self.inner.read().expect("BufferStore lock");
|
||||
inner.buffers.get(key).cloned()
|
||||
}
|
||||
|
||||
/// 更新 buffer。
|
||||
pub fn update(&self, key: &BufferKey, buffer: DocumentBuffer) {
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
inner.buffers.insert(key.clone(), buffer);
|
||||
}
|
||||
|
||||
/// 通过 ObjectWorkspacePath 获取 buffer。不存在则返回 None。
|
||||
pub fn get_by_path(&self, path: &ObjectWorkspacePath) -> Option<DocumentBuffer> {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
self.get(&key)
|
||||
}
|
||||
|
||||
/// 标记保存成功:更新 file_version、base_content_hash、清除 dirty 状态。
|
||||
pub fn mark_saved(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
file_version: String,
|
||||
content_hash: String,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get_mut(&key) {
|
||||
buf.mark_saved(file_version, content_hash);
|
||||
Some(buf.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记外部修改:如果 buffer 是 Clean → ExternalModified;是 Dirty → Stale。
|
||||
pub fn mark_external_modified(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
actor: Option<String>,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get_mut(&key) {
|
||||
buf.mark_external_modified(actor);
|
||||
Some(buf.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记 buffer 为 dirty(编辑器内容已修改)。
|
||||
pub fn mark_dirty(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
content_hash: String,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get_mut(&key) {
|
||||
buf.mark_dirty(content_hash);
|
||||
Some(buf.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
|
||||
pub fn init_buffer(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
file_version: Option<String>,
|
||||
base_content_hash: Option<String>,
|
||||
) -> DocumentBuffer {
|
||||
let mut buf = self.get_or_create(path);
|
||||
if file_version.is_some() || base_content_hash.is_some() {
|
||||
if let Some(fv) = file_version {
|
||||
buf.file_version = Some(fv);
|
||||
}
|
||||
if let Some(ch) = base_content_hash {
|
||||
buf.base_content_hash = Some(ch);
|
||||
}
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
self.update(&key, buf.clone());
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// 返回当前 buffer 数量(仅用于测试和监控)。
|
||||
pub fn buffer_count(&self) -> usize {
|
||||
let inner = self.inner.read().expect("BufferStore lock");
|
||||
inner.buffers.len()
|
||||
}
|
||||
|
||||
/// 清除所有 buffers(测试用途)。
|
||||
pub fn clear(&self) {
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
inner.buffers.clear();
|
||||
}
|
||||
|
||||
/// 返回当前所有 buffers 的拷贝(测试/监控用途)。
|
||||
pub fn all_buffers(&self) -> Vec<DocumentBuffer> {
|
||||
let inner = self.inner.read().expect("BufferStore lock");
|
||||
inner.buffers.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BufferStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 从 ObjectWorkspacePath 构建 buffer key 字符串(用于日志/调试)。
|
||||
pub fn buffer_key_string(path: &ObjectWorkspacePath) -> String {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
path.workspace_id,
|
||||
path.root_uri,
|
||||
path.relative_path,
|
||||
path.object_identity.document_id.as_deref().unwrap_or("_")
|
||||
)
|
||||
}
|
||||
|
||||
/// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。
|
||||
///
|
||||
/// 在 save_local_markdown_page、watcher event 和 Hermes 写入链中统一使用此函数构造路径。
|
||||
pub fn build_local_folder_workspace_path(
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
document_id: &str,
|
||||
) -> ObjectWorkspacePath {
|
||||
ObjectWorkspacePath {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
relative_path: relative_path.to_string(),
|
||||
object_identity: KernelObjectIdentity {
|
||||
object_kind: KernelObjectKind::Page,
|
||||
document_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
asset_id: None,
|
||||
},
|
||||
resource_kind: Some("document".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core_protocol::{
|
||||
DocBufferDirtyState, DocumentBuffer, KernelObjectIdentity, KernelObjectKind,
|
||||
ObjectWorkspacePath, WorkspaceSourceKind,
|
||||
};
|
||||
|
||||
fn make_test_workspace_path(
|
||||
workspace_id: &str,
|
||||
relative_path: &str,
|
||||
document_id: &str,
|
||||
) -> ObjectWorkspacePath {
|
||||
ObjectWorkspacePath {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: "file:///tmp/test-root".to_string(),
|
||||
relative_path: relative_path.to_string(),
|
||||
object_identity: KernelObjectIdentity {
|
||||
object_kind: KernelObjectKind::Page,
|
||||
document_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
asset_id: None,
|
||||
},
|
||||
resource_kind: Some("document".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_fresh_buffer(path: &ObjectWorkspacePath) -> DocumentBuffer {
|
||||
DocumentBuffer {
|
||||
workspace_path: path.clone(),
|
||||
file_version: Some("v1".to_string()),
|
||||
base_content_hash: Some("sha256:base".to_string()),
|
||||
current_content_hash: None,
|
||||
dirty_state: DocBufferDirtyState::Clean,
|
||||
last_loaded_at: Some(1_700_000_000_000i64),
|
||||
last_saved_at: Some(1_700_000_000_001i64),
|
||||
external_actor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_reuses_workspace_path_key() {
|
||||
let store = BufferStore::new();
|
||||
let path_a = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let path_b = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
|
||||
// 同一个路径应得同一个 buffer
|
||||
let buf1 = store.get_or_create(&path_a);
|
||||
let buf2 = store.get_or_create(&path_b);
|
||||
|
||||
assert_eq!(
|
||||
buf1.workspace_path.relative_path,
|
||||
buf2.workspace_path.relative_path
|
||||
);
|
||||
assert_eq!(
|
||||
BufferKey::from_workspace_path(&path_a),
|
||||
BufferKey::from_workspace_path(&path_b)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_marks_external_modified_for_clean_buffer() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let mut buf = make_fresh_buffer(&path);
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
store.update(&key, buf.clone());
|
||||
|
||||
buf.mark_external_modified(Some("external-editor".into()));
|
||||
store.update(&key, buf);
|
||||
|
||||
let result = store.get(&key).expect("buffer should exist");
|
||||
assert_eq!(result.dirty_state, DocBufferDirtyState::ExternalModified);
|
||||
assert_eq!(result.external_actor.as_deref(), Some("external-editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_marks_stale_for_dirty_buffer() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let mut buf = make_fresh_buffer(&path);
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
|
||||
// 先 mark_dirty
|
||||
buf.mark_dirty("sha256:dirty".into());
|
||||
store.update(&key, buf);
|
||||
|
||||
// 外部修改 → 应变为 Stale
|
||||
let result = store.mark_external_modified(&path, Some("external-editor".into()));
|
||||
assert!(result.is_some());
|
||||
assert_eq!(
|
||||
result.as_ref().unwrap().dirty_state,
|
||||
DocBufferDirtyState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
result.as_ref().unwrap().external_actor.as_deref(),
|
||||
Some("external-editor")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_mark_saved_after_page_body_write() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let mut buf = make_fresh_buffer(&path);
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
buf.mark_dirty("sha256:dirty".into());
|
||||
store.update(&key, buf);
|
||||
|
||||
// 保存后应变为 Clean,更新 file_version
|
||||
let result = store.mark_saved(&path, "v2".into(), "sha256:saved".into());
|
||||
assert!(result.is_some());
|
||||
let buf = result.unwrap();
|
||||
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
||||
assert_eq!(buf.file_version.as_deref(), Some("v2"));
|
||||
assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved"));
|
||||
assert!(!buf.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_different_paths_different_keys() {
|
||||
let store = BufferStore::new();
|
||||
let path_a = make_test_workspace_path("ws_1", "doc-a.md", "local-md:doc-a.md");
|
||||
let path_b = make_test_workspace_path("ws_2", "doc-b.md", "local-md:doc-b.md");
|
||||
|
||||
let key_a = BufferKey::from_workspace_path(&path_a);
|
||||
let key_b = BufferKey::from_workspace_path(&path_b);
|
||||
|
||||
assert_ne!(key_a, key_b);
|
||||
|
||||
let buf1 = store.get_or_create(&path_a);
|
||||
let buf2 = store.get_or_create(&path_b);
|
||||
|
||||
assert_ne!(
|
||||
buf1.workspace_path.workspace_id,
|
||||
buf2.workspace_path.workspace_id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_reuses_across_multiple_get_or_create_calls() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
|
||||
let buf1 = store.get_or_create(&path);
|
||||
let buf2 = store.get_or_create(&path);
|
||||
|
||||
// 同一个路径的 buffer 应该共享同一 key
|
||||
assert_eq!(
|
||||
BufferKey::from_workspace_path(&buf1.workspace_path),
|
||||
BufferKey::from_workspace_path(&buf2.workspace_path)
|
||||
);
|
||||
assert_eq!(store.buffer_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_init_buffer_sets_file_version() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
|
||||
let buf = store.init_buffer(&path, Some("v3".into()), Some("sha256:v3base".into()));
|
||||
assert_eq!(buf.file_version.as_deref(), Some("v3"));
|
||||
assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:v3base"));
|
||||
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
||||
|
||||
// 再次获取应保留状态
|
||||
let buf2 = store.get_or_create(&path);
|
||||
assert_eq!(buf2.file_version.as_deref(), Some("v3"));
|
||||
assert_eq!(store.buffer_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_mark_dirty_and_saved_round_trip() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc-cycle.md", "local-md:doc-cycle.md");
|
||||
|
||||
// init → mark_dirty → mark_saved → 应回到 Clean
|
||||
let _ = store.init_buffer(&path, Some("v1".into()), Some("sha256:base".into()));
|
||||
let dirty = store.mark_dirty(&path, "sha256:dirty".into());
|
||||
assert!(dirty.is_some());
|
||||
assert_eq!(dirty.unwrap().dirty_state, DocBufferDirtyState::Dirty);
|
||||
|
||||
let saved = store.mark_saved(&path, "v2".into(), "sha256:saved".into());
|
||||
assert!(saved.is_some());
|
||||
let buf = saved.unwrap();
|
||||
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
||||
assert_eq!(buf.file_version.as_deref(), Some("v2"));
|
||||
assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved"));
|
||||
}
|
||||
}
|
||||
@@ -1708,6 +1708,7 @@ pub async fn doc_markdown_edit(
|
||||
content: next_content,
|
||||
editor_source: Some("mnote.doc.markdown_edit".into()),
|
||||
},
|
||||
Some(&state.buffer_store),
|
||||
)?;
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
|
||||
@@ -233,6 +233,7 @@ async fn page_command(
|
||||
content,
|
||||
editor_source: Some("mnote.page.save".into()),
|
||||
},
|
||||
Some(&state.buffer_store),
|
||||
)?
|
||||
}
|
||||
"page.head.updateTitle" => {
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod acp_session_manager;
|
||||
pub mod acp_types;
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod document_buffer_store;
|
||||
pub mod editor_actor;
|
||||
pub mod error;
|
||||
pub mod hermes_tools;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use crate::routes::{local_workspace_id_from_root_uri, refresh_local_search_index_for_path};
|
||||
use notify::event::ModifyKind;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
@@ -12,6 +13,7 @@ use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
#[derive(Clone)]
|
||||
pub struct LocalFolderWatcherRegistry {
|
||||
inner: Arc<LocalFolderWatcherRegistryInner>,
|
||||
buffer_store: BufferStore,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||
@@ -23,11 +25,12 @@ impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherRegistry {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(buffer_store: BufferStore) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(LocalFolderWatcherRegistryInner {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
buffer_store,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +39,10 @@ impl LocalFolderWatcherRegistry {
|
||||
canonical_root: &Path,
|
||||
) -> Result<LocalFolderWatcherSubscription, String> {
|
||||
let key = canonical_root_uri(canonical_root);
|
||||
let channel = self.inner.get_or_create_channel(&key, canonical_root)?;
|
||||
let buffer_store = self.buffer_store.clone();
|
||||
let channel = self
|
||||
.inner
|
||||
.get_or_create_channel(&key, canonical_root, buffer_store)?;
|
||||
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(LocalFolderWatcherSubscription {
|
||||
receiver: channel.sender.subscribe(),
|
||||
@@ -63,6 +69,7 @@ impl LocalFolderWatcherRegistryInner {
|
||||
&self,
|
||||
key: &str,
|
||||
canonical_root: &Path,
|
||||
buffer_store: BufferStore,
|
||||
) -> Result<Arc<LocalFolderWatchChannel>, String> {
|
||||
if let Some(existing) = self
|
||||
.entries
|
||||
@@ -76,7 +83,7 @@ impl LocalFolderWatcherRegistryInner {
|
||||
|
||||
let channel = Arc::new(LocalFolderWatchChannel::new(
|
||||
key.to_string(),
|
||||
spawn_local_folder_watcher(key, canonical_root.to_path_buf())?,
|
||||
spawn_local_folder_watcher(key, canonical_root.to_path_buf(), buffer_store)?,
|
||||
));
|
||||
|
||||
let mut entries = self.entries.lock().expect("registry lock");
|
||||
@@ -157,6 +164,7 @@ impl Drop for LocalFolderWatcherSubscriptionGuard {
|
||||
fn spawn_local_folder_watcher(
|
||||
root_uri: &str,
|
||||
canonical_root: PathBuf,
|
||||
buffer_store: BufferStore,
|
||||
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
|
||||
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
@@ -174,6 +182,7 @@ fn spawn_local_folder_watcher(
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
|
||||
let sender_for_task = sender.clone();
|
||||
let root_uri_for_task = root_uri.to_string();
|
||||
let buffer_store_for_task = buffer_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let _watcher = watcher;
|
||||
loop {
|
||||
@@ -206,11 +215,29 @@ fn spawn_local_folder_watcher(
|
||||
if !is_markdown_path(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 外部文件变更→更新 BufferStore
|
||||
let document_id =
|
||||
format!("local-md:{}", encode_local_id_segment(&relative_path));
|
||||
if let Ok(ws_id) =
|
||||
local_workspace_id_from_root_uri(&root_uri_for_task)
|
||||
{
|
||||
let ws_path =
|
||||
crate::document_buffer_store::build_local_folder_workspace_path(
|
||||
&ws_id,
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
&document_id,
|
||||
);
|
||||
buffer_store_for_task
|
||||
.mark_external_modified(&ws_path, Some("external-editor".into()));
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri_for_task,
|
||||
"relativePath": relative_path,
|
||||
"documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)),
|
||||
"documentId": document_id,
|
||||
"eventKind": format!("{:?}", event.kind),
|
||||
"revision": event_revision(&path),
|
||||
});
|
||||
@@ -319,6 +346,7 @@ mod tests {
|
||||
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind,
|
||||
LocalFolderWatcherRegistry,
|
||||
};
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||
use notify::EventKind;
|
||||
|
||||
@@ -337,7 +365,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_root_subscribers_share_single_watcher() {
|
||||
let registry = LocalFolderWatcherRegistry::new();
|
||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
|
||||
let root = test_root("shared");
|
||||
|
||||
let first = registry.subscribe(&root).expect("first subscription");
|
||||
@@ -358,7 +386,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_roots_create_independent_watchers() {
|
||||
let registry = LocalFolderWatcherRegistry::new();
|
||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
|
||||
let first_root = test_root("first");
|
||||
let second_root = test_root("second");
|
||||
|
||||
|
||||
@@ -521,6 +521,7 @@ pub async fn content(
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<core_protocol::PageBodyWriteRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
@@ -548,7 +549,7 @@ pub async fn page_body_write(
|
||||
}
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = write_local_markdown_page_body(&body)?;
|
||||
let result = write_local_markdown_page_body(&body, Some(&state.buffer_store))?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -580,23 +581,26 @@ pub async fn save(
|
||||
.expected_file_version
|
||||
.as_deref()
|
||||
.or(body.conflict_detection_key.as_deref());
|
||||
let result = write_local_markdown_page_body(&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: body.workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version: expected_file_version.map(ToOwned::to_owned),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| "editorBlocks".into()),
|
||||
content: body.content.clone(),
|
||||
editor_source: body
|
||||
.editor_source
|
||||
.clone()
|
||||
.or_else(|| Some("documents/save-compat".into())),
|
||||
})?;
|
||||
let result = write_local_markdown_page_body(
|
||||
&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: body.workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version: expected_file_version.map(ToOwned::to_owned),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| "editorBlocks".into()),
|
||||
content: body.content.clone(),
|
||||
editor_source: body
|
||||
.editor_source
|
||||
.clone()
|
||||
.or_else(|| Some("documents/save-compat".into())),
|
||||
},
|
||||
Some(&state.buffer_store),
|
||||
)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
@@ -1368,10 +1372,10 @@ mod tests {
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::create_dir_all(root.join("Old Local Title")).expect("create local page bundle");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\nmnote_id: local-stable\ntitle: Old Title\n---\n# Old\n",
|
||||
root.join("Old Local Title").join("Old Local Title.md"),
|
||||
"# Old\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
@@ -1380,7 +1384,7 @@ mod tests {
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let document_id = "local-mdid:local-stable";
|
||||
let document_id = "local-md:Old~20Local~20Title~2FOld~20Local~20Title.md";
|
||||
|
||||
let title_response = app()
|
||||
.oneshot(
|
||||
@@ -1404,6 +1408,14 @@ mod tests {
|
||||
.await
|
||||
.expect("title response");
|
||||
assert_eq!(title_response.status(), StatusCode::OK);
|
||||
let title_body = to_bytes(title_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("title body");
|
||||
let title_payload: Value = serde_json::from_slice(&title_body).expect("title json");
|
||||
let renamed_document_id = title_payload["result"]["documentId"]
|
||||
.as_str()
|
||||
.expect("renamed document id")
|
||||
.to_string();
|
||||
|
||||
let save_response = app()
|
||||
.oneshot(
|
||||
@@ -1415,7 +1427,7 @@ mod tests {
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"documentId": renamed_document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"content": [
|
||||
@@ -1460,7 +1472,7 @@ mod tests {
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"documentId": renamed_document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"options": {
|
||||
@@ -1476,17 +1488,23 @@ mod tests {
|
||||
.expect("options response");
|
||||
assert_eq!(options_response.status(), StatusCode::OK);
|
||||
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("mnote_id: local-stable"));
|
||||
assert!(markdown.contains("title: New Local Title"));
|
||||
let markdown =
|
||||
std::fs::read_to_string(root.join("New Local Title").join("New Local Title.md"))
|
||||
.expect("read md");
|
||||
assert!(markdown.contains("## Saved Heading"));
|
||||
assert!(markdown.contains("Saved body"));
|
||||
|
||||
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
|
||||
.expect("read page options");
|
||||
let options_json: Value = serde_json::from_str(&options).expect("options json");
|
||||
assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
|
||||
assert_eq!(options_json["pages"][document_id]["showToc"], false);
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["wideLayout"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["showToc"],
|
||||
false
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1510,7 +1528,7 @@ mod tests {
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let document_id = "local-mdid:expected-file-version";
|
||||
let document_id = "local-md:README.md";
|
||||
let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate(
|
||||
&root_uri,
|
||||
document_id,
|
||||
@@ -1582,7 +1600,7 @@ mod tests {
|
||||
assert!(payload["details"]["conflict"]["currentDiskVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:local-mdid:expected-file-version:"));
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(payload["details"]["conflict"]["suggestedActions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::context::{stable_actor_id, RequestContext};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, local_access_policy_path_display,
|
||||
create_default_local_workspace_for_actor, ensure_local_workspace_read_access,
|
||||
is_local_access_policy_admin_context, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries, local_access_policy_path_display,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
@@ -22,10 +23,12 @@ use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use leptos::prelude::InnerHtmlAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
|
||||
@@ -36,6 +39,8 @@ const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
|
||||
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
|
||||
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
|
||||
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -305,24 +310,58 @@ pub async fn root_entry(
|
||||
Some(root_uri.to_string()),
|
||||
)
|
||||
} else if should_render_local_first_landing {
|
||||
let workspace_id = "local-first-entry".to_string();
|
||||
let payload = create_default_local_workspace_for_actor(
|
||||
&context.auth.actor_id,
|
||||
&context.auth.actor_type,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_uri = payload
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("rootUri"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
|
||||
})?
|
||||
.to_string();
|
||||
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("local-folder")
|
||||
.to_string();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
&snapshot.dataset,
|
||||
&workspace_id,
|
||||
None,
|
||||
requested_page_id.as_deref(),
|
||||
"我的空间",
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html =
|
||||
render_local_sidebar_tree_html(&root_uri, selected_active_page_id.as_deref())?;
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref())?;
|
||||
(
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
sidebar_tree_html,
|
||||
file_tree_html,
|
||||
selected_active_page_id,
|
||||
Some("local_folder".to_string()),
|
||||
Some(root_uri),
|
||||
)
|
||||
} else {
|
||||
let workspace_id =
|
||||
@@ -448,6 +487,7 @@ pub async fn root_entry(
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
page_subtree_json={page_subtree_json}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
enable_tree_live={active_source_kind.as_deref() != Some("local_folder")}
|
||||
/>
|
||||
});
|
||||
let body_extra = format!(
|
||||
@@ -475,7 +515,7 @@ pub async fn root_entry(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}">
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
@@ -483,6 +523,8 @@ pub async fn root_entry(
|
||||
escape_html(&html_title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
|
||||
escape_html(active_root_uri.as_deref().unwrap_or("")),
|
||||
content,
|
||||
body_extra
|
||||
))
|
||||
@@ -506,6 +548,81 @@ pub async fn trash_entry(
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let source_kind = query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if source_kind == Some("local_folder") {
|
||||
let root_uri = query
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let workspace_id =
|
||||
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
|
||||
let file_tree_html = render_local_file_tree_html(root_uri, None).unwrap_or_default();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
&workspace_id,
|
||||
None,
|
||||
"我的空间",
|
||||
);
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
let trash_workbench_html = render_local_trash_workbench_html(
|
||||
&workspace_id,
|
||||
root_uri,
|
||||
&load_local_trash_entries(root_uri)?,
|
||||
);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::layout::PageLayout
|
||||
current_nav="trash"
|
||||
sidebar_tree_html={sidebar_tree_html.clone()}
|
||||
workspace_name={"我的空间".to_string()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
topbar_title={"垃圾箱".to_string()}
|
||||
enable_tree_live={false}
|
||||
>
|
||||
<div inner_html={trash_workbench_html}></div>
|
||||
</crate::ssr::pages::layout::PageLayout>
|
||||
});
|
||||
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>本地文件夹垃圾箱</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(root_uri),
|
||||
content,
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
@@ -815,6 +932,202 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_local_trash_workbench_html(
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
entries: &BTreeMap<String, crate::routes::local_folder_source::LocalTrashEntry>,
|
||||
) -> String {
|
||||
let mut document_rows = Vec::new();
|
||||
let mut resource_rows = Vec::new();
|
||||
for (entry_id, entry) in entries {
|
||||
let deleted_at = entry.deleted_at_ms.to_string();
|
||||
let original = escape_html(&entry.original_relative_path);
|
||||
let trash_path = escape_html(&entry.trash_relative_path);
|
||||
let kind = escape_html(&entry.resource_kind);
|
||||
let row = format!(
|
||||
r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}">
|
||||
<div class="mnote-trash-row-main">
|
||||
<span class="mnote-trash-kind">{kind}</span>
|
||||
<span class="mnote-trash-title">{original}</span>
|
||||
<span class="mnote-trash-meta">回收站:{trash_path} · {deleted_at}</span>
|
||||
</div>
|
||||
<div class="mnote-trash-actions">
|
||||
<button type="button" data-trash-action="local-restore" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">恢复</button>
|
||||
<button type="button" data-trash-action="local-purge" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">彻底删除</button>
|
||||
</div>
|
||||
</article>"#,
|
||||
entry_id = escape_html(entry_id),
|
||||
kind = kind,
|
||||
original = original,
|
||||
trash_path = trash_path,
|
||||
deleted_at = escape_html(&deleted_at),
|
||||
command_id = escape_html(local_trash_command_id(entry_id, entry).as_str()),
|
||||
);
|
||||
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
||||
document_rows.push(row);
|
||||
} else {
|
||||
resource_rows.push(row);
|
||||
}
|
||||
}
|
||||
let document_body = if document_rows.is_empty() {
|
||||
r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string()
|
||||
} else {
|
||||
document_rows.join("")
|
||||
};
|
||||
let resource_body = if resource_rows.is_empty() {
|
||||
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
|
||||
} else {
|
||||
resource_rows.join("")
|
||||
};
|
||||
format!(
|
||||
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-trash-source-kind="local_folder" data-workspace-id="{workspace_id}" data-root-uri="{root_uri}" data-trash-fetch-path="/trash">
|
||||
<header class="mnote-trash-header">
|
||||
<h1>本地文件夹垃圾箱</h1>
|
||||
<p>本地删除项保存在当前目录的 <code>.mnote/trash</code> 与 <code>.mnote/trash-index.json</code> 中。</p>
|
||||
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
|
||||
</header>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
|
||||
<button type="button" data-trash-action="local-empty-documents"{document_empty_disabled}>清空页面垃圾箱</button>
|
||||
</div>
|
||||
{document_body}
|
||||
</section>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
|
||||
<button type="button" data-trash-action="local-empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
|
||||
</div>
|
||||
{resource_body}
|
||||
</section>
|
||||
</section>
|
||||
<script>
|
||||
(function() {{
|
||||
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!root) return;
|
||||
var workspaceId = root.getAttribute('data-workspace-id') || '';
|
||||
var rootUri = root.getAttribute('data-root-uri') || '';
|
||||
function setStatus(message, failed) {{
|
||||
var status = root.querySelector('[data-trash-status]');
|
||||
if (!status) return;
|
||||
status.textContent = message || '';
|
||||
status.setAttribute('data-type', failed ? 'error' : 'success');
|
||||
}}
|
||||
function readJson(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
|
||||
return payload;
|
||||
}});
|
||||
}}
|
||||
function postJson(url, body) {{
|
||||
return fetch(url, {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify(body || {{}})
|
||||
}}).then(readJson);
|
||||
}}
|
||||
function refresh() {{
|
||||
var url = new URL('/trash', window.location.origin);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
return fetch(url.toString(), {{ headers: {{ 'x-mnote-trash-live-refresh': '1' }} }})
|
||||
.then(function(response) {{ return response.text().then(function(html) {{ return {{ response: response, html: html }}; }}); }})
|
||||
.then(function(result) {{
|
||||
if (!result.response.ok) throw new Error('trash_live_refresh_failed_' + result.response.status);
|
||||
var parsed = new DOMParser().parseFromString(result.html, 'text/html');
|
||||
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
|
||||
root.innerHTML = nextRoot.innerHTML;
|
||||
return true;
|
||||
}})
|
||||
.catch(function(error) {{
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
return false;
|
||||
}});
|
||||
}}
|
||||
root.addEventListener('click', function(event) {{
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
|
||||
if (!button || button.disabled) return;
|
||||
var action = button.getAttribute('data-trash-action');
|
||||
var entryId = button.getAttribute('data-trash-entry-id') || '';
|
||||
var documentId = button.getAttribute('data-document-id') || entryId;
|
||||
var kind = button.getAttribute('data-resource-kind') || '';
|
||||
if (action === 'local-restore' || action === 'local-purge') {{
|
||||
if (action === 'local-purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
postJson('/api/tree/commands', {{
|
||||
action: action === 'local-restore' ? 'restore' : 'purge',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
documentId: entryId
|
||||
}}).then(function() {{
|
||||
return refresh();
|
||||
}}).then(function() {{
|
||||
setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
}}
|
||||
if (action === 'local-empty-documents' || action === 'local-empty-resources') {{
|
||||
var selector = action === 'local-empty-documents'
|
||||
? '[data-trash-row="local"][data-resource-kind="markdown"], [data-trash-row="local"][data-resource-kind="markdown_bundle"]'
|
||||
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
|
||||
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
|
||||
if (rows.length === 0) return;
|
||||
if (!window.confirm('清空后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
Promise.all(rows.map(function(row) {{
|
||||
var entryId = row.getAttribute('data-trash-entry-id') || '';
|
||||
return postJson('/api/tree/commands', {{
|
||||
action: 'purge',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
documentId: entryId
|
||||
}});
|
||||
}})).then(function() {{
|
||||
return refresh();
|
||||
}}).then(function() {{
|
||||
setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
}}
|
||||
}});
|
||||
}})();
|
||||
</script>"#,
|
||||
workspace_id = escape_html(workspace_id),
|
||||
root_uri = escape_html(root_uri),
|
||||
document_count = document_rows.len(),
|
||||
resource_count = resource_rows.len(),
|
||||
document_empty_disabled = if document_rows.is_empty() {
|
||||
" disabled"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
resource_empty_disabled = if resource_rows.is_empty() {
|
||||
" disabled"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
document_body = document_body,
|
||||
resource_body = resource_body,
|
||||
)
|
||||
}
|
||||
|
||||
fn local_trash_command_id(
|
||||
entry_id: &str,
|
||||
entry: &crate::routes::local_folder_source::LocalTrashEntry,
|
||||
) -> String {
|
||||
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
||||
return entry.document_id.clone();
|
||||
}
|
||||
entry_id.to_string()
|
||||
}
|
||||
|
||||
fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] {
|
||||
dataset
|
||||
.get(key)
|
||||
@@ -1334,6 +1647,10 @@ fn build_auth_proxy_response(
|
||||
COOKIE_CONVEX_AUTH_REFRESH_TOKEN,
|
||||
tokens.get("refreshToken"),
|
||||
);
|
||||
if let Some(actor_id) = resolve_mnote_actor_id(&value, tokens, context) {
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id);
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
|
||||
}
|
||||
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
|
||||
}
|
||||
}
|
||||
@@ -1342,6 +1659,61 @@ fn build_auth_proxy_response(
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_mnote_actor_id(
|
||||
value: &serde_json::Value,
|
||||
tokens: &serde_json::Value,
|
||||
context: &RequestContext,
|
||||
) -> Option<String> {
|
||||
[
|
||||
"/userId",
|
||||
"/user/id",
|
||||
"/user/_id",
|
||||
"/user/subject",
|
||||
"/profile/userId",
|
||||
"/profile/id",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| {
|
||||
value
|
||||
.pointer(pointer)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(stable_actor_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
tokens
|
||||
.get("token")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(extract_actor_id_from_jwt)
|
||||
})
|
||||
.or_else(|| {
|
||||
let actor_id = stable_actor_id(&context.auth.actor_id)?;
|
||||
if actor_id == "anonymous" {
|
||||
None
|
||||
} else {
|
||||
Some(actor_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_actor_id_from_jwt(token: &str) -> Option<String> {
|
||||
let payload_segment = token.split('.').nth(1)?;
|
||||
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
|
||||
let payload: Value = serde_json::from_slice(&decoded).ok()?;
|
||||
["sub", "userId", "id", "_id"].iter().find_map(|key| {
|
||||
payload
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(stable_actor_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
|
||||
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
||||
headers.append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_auth_cookie_from_value(
|
||||
headers: &mut axum::http::HeaderMap,
|
||||
name: &'static str,
|
||||
@@ -1360,6 +1732,8 @@ fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
|
||||
expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
|
||||
expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
|
||||
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
|
||||
}
|
||||
|
||||
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
|
||||
@@ -1456,9 +1830,20 @@ mod tests {
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use base64::Engine;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn temp_root(name: &str) -> std::path::PathBuf {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("{name}-{stamp}"));
|
||||
std::fs::create_dir_all(&path).expect("temp root");
|
||||
path
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
|
||||
}
|
||||
@@ -1526,6 +1911,7 @@ mod tests {
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"value": {
|
||||
"userId": "user_demo",
|
||||
"tokens": {
|
||||
"token": "jwt-demo",
|
||||
"refreshToken": "refresh-demo"
|
||||
@@ -1542,6 +1928,43 @@ mod tests {
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_convex_auth_upstream_with_token(token: String) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("convex auth listener");
|
||||
let addr = listener.local_addr().expect("convex auth addr");
|
||||
let app = axum::Router::new().route(
|
||||
"/api/action",
|
||||
post(move || {
|
||||
let token = token.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"value": {
|
||||
"tokens": {
|
||||
"token": token,
|
||||
"refreshToken": "refresh-demo"
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("convex auth server");
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn unsigned_jwt_with_subject(subject: &str) -> String {
|
||||
let payload = serde_json::json!({ "sub": subject });
|
||||
let encoded_payload =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||
format!("header.{encoded_payload}.signature")
|
||||
}
|
||||
|
||||
async fn spawn_legacy_auth_upstream() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -1745,6 +2168,66 @@ mod tests {
|
||||
assert!(!html.contains("window.location.reload"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trash_entry_renders_local_folder_trash_workbench() {
|
||||
let root = temp_root("mnote-local-folder-trash-entry");
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
|
||||
std::fs::create_dir_all(root.join(".mnote").join("trash")).expect("create trash dir");
|
||||
std::fs::write(
|
||||
root.join(".mnote").join("trash-index.json"),
|
||||
serde_json::json!({
|
||||
"entries": {
|
||||
"local-md:Deleted~2FDeleted.md": {
|
||||
"documentId": "local-md:Deleted~2FDeleted.md",
|
||||
"resourceKind": "markdown_bundle",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": "Deleted",
|
||||
"trashRelativePath": ".mnote/trash/Deleted",
|
||||
"deletedAtMs": 1770000000000u64
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write trash index");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let uri = format!(
|
||||
"/trash?sourceKind=local_folder&rootUri={}",
|
||||
root_uri.replace(':', "%3A").replace('/', "%2F")
|
||||
);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-trash-source-kind="local_folder""#));
|
||||
assert!(html.contains("Deleted"));
|
||||
assert!(html.contains(r#"data-trash-action="local-restore""#));
|
||||
assert!(html.contains(r#"data-trash-action="local-purge""#));
|
||||
assert!(html.contains(r#"data-trash-action="local-empty-documents""#));
|
||||
assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
|
||||
let selected = super::choose_root_entry_active_page_id(
|
||||
@@ -1860,6 +2343,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_first_landing_without_convex() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let base = temp_root("mnote-root-local-first-landing");
|
||||
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
|
||||
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1871,15 +2359,19 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(html.contains("初始化的新页面"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2060,6 +2552,52 @@ mod tests {
|
||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_initializes_default_local_workspace_page() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let base = temp_root("mnote-root-default-local-workspace");
|
||||
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
let expected_root = base
|
||||
.join("users")
|
||||
.join("user_real")
|
||||
.join("workspaces")
|
||||
.join("my-space");
|
||||
assert!(expected_root
|
||||
.join("初始化的新页面")
|
||||
.join("初始化的新页面.md")
|
||||
.exists());
|
||||
assert!(html.contains("初始化的新页面"));
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
||||
assert!(!html.contains("当前还没有可显示的本地工作区"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
|
||||
assert!(html.contains(r#""transport":"disabled""#));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
@@ -2150,6 +2688,12 @@ mod tests {
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=user_demo")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_type=user")));
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -2158,6 +2702,42 @@ mod tests {
|
||||
assert_eq!(payload["tokens"]["refreshToken"], "dummy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_api_normalizes_session_subject_before_setting_actor_cookie() {
|
||||
let token = unsigned_jwt_with_subject("user_stable|session_rotating");
|
||||
let convex_url = spawn_convex_auth_upstream_with_token(token).await;
|
||||
let response = app_with_config_and_convex_url(
|
||||
"http://127.0.0.1:3100".into(),
|
||||
false,
|
||||
Some(convex_url),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#,
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cookies = response.headers().get_all(header::SET_COOKIE);
|
||||
let values = cookies
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=user_stable")));
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=user_stable|session_rotating")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
|
||||
let legacy_base_url = spawn_legacy_auth_upstream().await;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
@@ -22,6 +26,8 @@ pub struct KernelProjectionQuery {
|
||||
pub depth: Option<u32>,
|
||||
pub query: Option<String>,
|
||||
pub max_results: Option<usize>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -65,6 +71,30 @@ async fn project_projection(
|
||||
Query(query): Query<KernelProjectionQuery>,
|
||||
projection: KernelProjectionKind,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let source_kind = query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if source_kind == Some("local_folder") {
|
||||
let root_uri = query
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let snapshot = if projection == KernelProjectionKind::FileTree {
|
||||
load_local_folder_file_tree_snapshot(root_uri)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
return Ok(ok_response(&context, snapshot.projection));
|
||||
}
|
||||
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
@@ -189,6 +219,7 @@ pub async fn graph(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
@@ -296,6 +327,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_short_circuit_local_folder_without_convex_dataset() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-kernel-projection-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("本地页面")).expect("create local page bundle");
|
||||
std::fs::write(root.join("本地页面").join("本地页面.md"), "")
|
||||
.expect("write local markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/tree/projections/file?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&rootNodeId=local-md:%E6%9C%AC%E5%9C%B0%E9%A1%B5%E9%9D%A2~2F%E6%9C%AC%E5%9C%B0%E9%A1%B5%E9%9D%A2.md"
|
||||
))
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["result"]["projection"], "file_tree");
|
||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||
assert!(payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.any(|item| item["title"] == "本地页面.md"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_keep_ok_response_shape() {
|
||||
let response = app()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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("\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("示例图片"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::encode_local_id_segment;
|
||||
use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
@@ -411,11 +412,7 @@ fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocum
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let document_id = parsed
|
||||
.mnote_id
|
||||
.as_deref()
|
||||
.map(|id| format!("local-mdid:{id}"))
|
||||
.unwrap_or_else(|| format!("local-md:{}", relative_path.replace('/', "~2F")));
|
||||
let document_id = format!("local-md:{}", encode_local_id_segment(&relative_path));
|
||||
let metadata = fs::metadata(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_stat_failed",
|
||||
@@ -849,6 +846,34 @@ mod tests {
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
|
||||
|
||||
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
|
||||
let child_search = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
"local-ws-test",
|
||||
"office.xlsx",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("child search");
|
||||
let child_result = child_search["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["path"].as_str() == Some("docs/child.md"))
|
||||
.expect("child in search results");
|
||||
assert_eq!(
|
||||
child_result["documentId"].as_str(),
|
||||
Some("local-md:docs~2Fchild.md")
|
||||
);
|
||||
assert_ne!(
|
||||
child_result["documentId"].as_str(),
|
||||
Some("local-mdid:child-page")
|
||||
);
|
||||
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
@@ -923,7 +948,7 @@ mod tests {
|
||||
.iter()
|
||||
.find(|document| document.path == "docs/child.md")
|
||||
.expect("child document");
|
||||
assert_eq!(child.title, "Child Updated");
|
||||
assert_eq!(child.title, "child");
|
||||
assert!(child.raw_text.contains("ChangedToken"));
|
||||
|
||||
fs::remove_file(root.join("docs").join("child.md")).expect("remove child");
|
||||
|
||||
@@ -2,9 +2,13 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, ensure_local_workspace_read_access, read_local_mindmap_data,
|
||||
write_local_mindmap_data,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, fetch_query_data_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
fetch_documents_meta_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -26,6 +30,8 @@ pub struct MindmapQueryParams {
|
||||
pub query_name: Option<String>,
|
||||
pub root_node_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -38,6 +44,8 @@ pub struct MindmapCommandRequest {
|
||||
pub workspace_id: Option<String>,
|
||||
pub data: Option<Value>,
|
||||
pub create_only: Option<bool>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
}
|
||||
|
||||
fn default_mindmap_data() -> Value {
|
||||
@@ -81,6 +89,25 @@ fn read_workspace_id_from_meta(meta: &Value) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn is_local_folder_source(source_kind: Option<&str>) -> bool {
|
||||
source_kind
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == "local_folder")
|
||||
}
|
||||
|
||||
fn local_root_uri<'a>(
|
||||
query_root_uri: Option<&'a str>,
|
||||
body_root_uri: Option<&'a str>,
|
||||
) -> Result<&'a str, WebError> {
|
||||
query_root_uri
|
||||
.or(body_root_uri)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地思维导图 rootUri")
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_mindmap_workspace_id(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -122,6 +149,29 @@ pub async fn get_mindmap(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
if is_local_folder_source(params.source_kind.as_deref()) {
|
||||
let root_uri = local_root_uri(params.root_uri.as_deref(), None)?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let data = read_local_mindmap_data(root_uri, document_id, mindmap_id)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let query_name = resolve_query_name(¶ms);
|
||||
let result = execute_runtime_query_against_data(
|
||||
&context,
|
||||
params.workspace_id.as_deref(),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: query_name.into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"rootNodeId": params.root_node_id,
|
||||
"workspaceId": params.workspace_id,
|
||||
}),
|
||||
},
|
||||
data,
|
||||
)?;
|
||||
return Ok((StatusCode::OK, response_headers(), Json(result)));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
|
||||
let query_name = resolve_query_name(¶ms);
|
||||
@@ -148,6 +198,7 @@ pub async fn apply_mindmap_command(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path((document_id, mindmap_id)): Path<(String, String)>,
|
||||
Query(params): Query<MindmapQueryParams>,
|
||||
Json(body): Json<MindmapCommandRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = document_id.trim();
|
||||
@@ -170,6 +221,101 @@ pub async fn apply_mindmap_command(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
|
||||
let local_source_kind = body
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.or(params.source_kind.as_deref());
|
||||
if is_local_folder_source(local_source_kind) {
|
||||
let root_uri = local_root_uri(params.root_uri.as_deref(), body.root_uri.as_deref())?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
if command_name == Some("mindmap.command.apply") {
|
||||
let current = read_local_mindmap_data(root_uri, document_id, mindmap_id)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let applied = apply_mindmap_kernel_commands_to_value(¤t, &body.commands)
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(&context))?;
|
||||
if !applied.errors.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mindmap_command_failed",
|
||||
applied.errors.join("; "),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "local_mindmap_command_apply"));
|
||||
}
|
||||
let write_result = write_local_mindmap_data(
|
||||
root_uri,
|
||||
document_id,
|
||||
mindmap_id,
|
||||
applied.data.clone(),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let projection = execute_runtime_query_against_data(
|
||||
&context,
|
||||
body.workspace_id.as_deref(),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "mindmap.simple_mind_map_scene.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"workspaceId": body.workspace_id,
|
||||
}),
|
||||
},
|
||||
applied.data,
|
||||
)?;
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
response_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"sourceKind": "local_folder",
|
||||
"commandName": "mindmap.command.apply",
|
||||
"applied": applied.applied,
|
||||
"errors": applied.errors,
|
||||
"projectionRevision": body.projection_revision,
|
||||
"writeResult": write_result,
|
||||
"kernelRevision": projection.get("kernelRevision").cloned().unwrap_or(json!(1)),
|
||||
"root": projection.get("root").cloned().unwrap_or(Value::Null),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let data = body.data.unwrap_or_else(default_mindmap_data);
|
||||
let write_result = write_local_mindmap_data(
|
||||
root_uri,
|
||||
document_id,
|
||||
mindmap_id,
|
||||
data.clone(),
|
||||
body.create_only.unwrap_or(false),
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let projection = execute_runtime_query_against_data(
|
||||
&context,
|
||||
body.workspace_id.as_deref(),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "mindmap.simple_mind_map_scene.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"workspaceId": body.workspace_id,
|
||||
}),
|
||||
},
|
||||
data,
|
||||
)?;
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
response_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"sourceKind": "local_folder",
|
||||
"commandName": "mindmaps.put",
|
||||
"writeResult": write_result,
|
||||
"kernelRevision": projection.get("kernelRevision").cloned().unwrap_or(json!(1)),
|
||||
"root": projection.get("root").cloned().unwrap_or(Value::Null),
|
||||
})),
|
||||
));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_mindmap_workspace_id(&state, &context, body.workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
@@ -323,6 +469,7 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -354,6 +501,26 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
fn temp_root(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{}-{}", name, std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp root");
|
||||
root
|
||||
}
|
||||
|
||||
fn encode_query_value(value: &str) -> String {
|
||||
value.replace(':', "%3A").replace('/', "%2F")
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &PathBuf) -> String {
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"dev-user", &root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
root_uri
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_put_derives_workspace_and_returns_tree_artifacts() {
|
||||
let response = app()
|
||||
@@ -450,4 +617,142 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_mindmap_put_writes_file_and_get_reads_back() {
|
||||
let root = temp_root("mnote-local-mindmap-api-put-get");
|
||||
let root_uri = init_local_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "").expect("write page");
|
||||
let encoded_root = encode_query_value(&root_uri);
|
||||
let encoded_mindmap_id = encode_query_value("思维导图123456.json");
|
||||
|
||||
let put_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!(
|
||||
"/api/mindmap/local-md:Page~2FPage.md/{encoded_mindmap_id}?sourceKind=local_folder&rootUri={encoded_root}"
|
||||
))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"data": {
|
||||
"data": {"text": "本地主题", "uid": "root"},
|
||||
"children": []
|
||||
},
|
||||
"createOnly": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(put_response.status(), StatusCode::OK);
|
||||
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let put_payload: Value = serde_json::from_slice(&put_body).expect("json");
|
||||
assert_eq!(put_payload["sourceKind"], "local_folder");
|
||||
assert_eq!(put_payload["commandName"], "mindmaps.put");
|
||||
assert_eq!(
|
||||
put_payload["writeResult"]["relativePath"],
|
||||
"Page/思维导图123456.json"
|
||||
);
|
||||
assert!(
|
||||
root.join("Page").join("思维导图123456.json").is_file(),
|
||||
"mindmap json should be persisted next to the page markdown"
|
||||
);
|
||||
|
||||
let get_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/mindmap/local-md:Page~2FPage.md/{encoded_mindmap_id}?sourceKind=local_folder&rootUri={encoded_root}&view=simple_mind_map_scene"
|
||||
))
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
let get_body = to_bytes(get_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
|
||||
assert_eq!(get_payload["root"]["data"]["text"], "本地主题");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_mindmap_command_apply_updates_persisted_file() {
|
||||
let root = temp_root("mnote-local-mindmap-api-command-apply");
|
||||
let root_uri = init_local_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "").expect("write page");
|
||||
std::fs::write(
|
||||
root.join("Page").join("思维导图123456.json"),
|
||||
json!({
|
||||
"data": {"text": "旧主题", "uid": "root"},
|
||||
"children": []
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write mindmap");
|
||||
let encoded_root = encode_query_value(&root_uri);
|
||||
let encoded_mindmap_id = encode_query_value("思维导图123456.json");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!(
|
||||
"/api/mindmap/local-md:Page~2FPage.md/{encoded_mindmap_id}?sourceKind=local_folder&rootUri={encoded_root}"
|
||||
))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"commandName": "mindmap.command.apply",
|
||||
"commands": [
|
||||
{
|
||||
"type": "updateText",
|
||||
"mindmapId": "思维导图123456.json",
|
||||
"nodeId": "root",
|
||||
"text": "新主题"
|
||||
}
|
||||
],
|
||||
"projectionRevision": 1
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["sourceKind"], "local_folder");
|
||||
assert_eq!(payload["commandName"], "mindmap.command.apply");
|
||||
assert_eq!(payload["root"]["data"]["text"], "新主题");
|
||||
|
||||
let saved = std::fs::read_to_string(root.join("Page").join("思维导图123456.json"))
|
||||
.expect("read saved mindmap");
|
||||
let saved_json: Value = serde_json::from_str(&saved).expect("saved json");
|
||||
assert_eq!(saved_json["data"]["text"], "新主题");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,46 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::web_shell::{
|
||||
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
|
||||
load_workspace_shell_projection,
|
||||
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
};
|
||||
use crate::ssr::pages::mindmap::MindmapPage;
|
||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapShellQuery {
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn mindmap_object_shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
Query(query): Query<MindmapShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let aggregate = build_page_aggregate_snapshot(&state, &context, &doc_id, None, None, None)
|
||||
.await
|
||||
.ok();
|
||||
let source_kind = query.source_kind.as_deref();
|
||||
let root_uri = query.root_uri.as_deref();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
&doc_id,
|
||||
query.workspace_id.as_deref(),
|
||||
source_kind,
|
||||
root_uri,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
let workspace_id = aggregate
|
||||
.as_ref()
|
||||
.map(|value| value.identity.workspace_id.clone())
|
||||
@@ -33,44 +52,69 @@ pub async fn mindmap_object_shell(
|
||||
.map(|value| value.head.title.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "思维导图".to_string());
|
||||
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) =
|
||||
if let Some(workspace_id) = workspace_id.as_deref() {
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let active_filetree_row_id = format!("asset:{mindmap_id}");
|
||||
let file_tree_html = load_file_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
Some(active_filetree_row_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
Some(sidebar_tree_html),
|
||||
Some(workspace_sidebar_html),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
let is_local_folder = source_kind.map(str::trim) == Some("local_folder");
|
||||
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder {
|
||||
let root_uri = root_uri.unwrap_or_default();
|
||||
let sidebar_tree_html =
|
||||
render_local_sidebar_tree_html(root_uri, Some(&doc_id)).unwrap_or_default();
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(root_uri, Some(&doc_id)).unwrap_or_default();
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id.as_deref().unwrap_or("local-folder"),
|
||||
Some(&doc_id),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
Some(sidebar_tree_html),
|
||||
Some(workspace_sidebar_html),
|
||||
)
|
||||
} else if let Some(workspace_id) = workspace_id.as_deref() {
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let active_filetree_row_id = format!("asset:{mindmap_id}");
|
||||
let file_tree_html = load_file_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
Some(active_filetree_row_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
Some(sidebar_tree_html),
|
||||
Some(workspace_sidebar_html),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
let editor_bootstrap = json!({
|
||||
"documentId": format!("__mindmap_object__:{doc_id}:{mindmap_id}"),
|
||||
"workspaceId": workspace_id
|
||||
@@ -99,6 +143,8 @@ pub async fn mindmap_object_shell(
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id
|
||||
},
|
||||
"sourceKind": source_kind.unwrap_or("convex_workspace"),
|
||||
"rootUri": root_uri.unwrap_or(""),
|
||||
"revision": serde_json::Value::Null,
|
||||
"conflictDetectionKey": serde_json::Value::Null,
|
||||
"pageOptions": {
|
||||
@@ -114,6 +160,8 @@ pub async fn mindmap_object_shell(
|
||||
"shell": "mindmap",
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"sourceKind": source_kind.unwrap_or("convex_workspace"),
|
||||
"rootUri": root_uri.unwrap_or(""),
|
||||
"projection": {
|
||||
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
|
||||
"runtime": "simple-mind-map",
|
||||
@@ -151,7 +199,7 @@ pub async fn mindmap_object_shell(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
|
||||
{}
|
||||
<script id="__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
|
||||
@@ -162,6 +210,8 @@ pub async fn mindmap_object_shell(
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&doc_id),
|
||||
escape_html(&mindmap_id),
|
||||
escape_html(source_kind.unwrap_or("convex_workspace")),
|
||||
escape_html(root_uri.unwrap_or("")),
|
||||
body_content,
|
||||
escape_script_json(&editor_bootstrap_json),
|
||||
escape_script_json(&contract_json),
|
||||
|
||||
@@ -443,48 +443,8 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
||||
rows
|
||||
}
|
||||
|
||||
fn short_mindmap_file_name(raw: &str, asset_id: Option<&str>) -> String {
|
||||
let source = asset_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(raw.trim());
|
||||
let digits = source
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|ch| ch.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
let suffix = if digits.len() >= 4 {
|
||||
digits[digits.len().saturating_sub(4)..].to_string()
|
||||
} else {
|
||||
source
|
||||
.trim_start_matches("mindmap")
|
||||
.trim_start_matches(|ch| ch == '-' || ch == '_')
|
||||
.chars()
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
};
|
||||
if suffix.trim().is_empty() {
|
||||
"思维导图.json".to_string()
|
||||
} else {
|
||||
format!("思维导图-{suffix}.json")
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_filetree_mindmap_title(
|
||||
raw_title: &str,
|
||||
asset_id: Option<&str>,
|
||||
icon_kind: &str,
|
||||
resource_kind: Option<&str>,
|
||||
) -> String {
|
||||
fn normalize_filetree_mindmap_title(raw_title: &str) -> String {
|
||||
let title = raw_title.trim();
|
||||
let is_mindmap = icon_kind == "mindmap" || resource_kind == Some("mindmap");
|
||||
let generated = title.starts_with("mindmap-") || title.starts_with("mindmap_");
|
||||
if is_mindmap && (generated || title.chars().count() > 24) {
|
||||
return short_mindmap_file_name(title, asset_id);
|
||||
}
|
||||
if title.is_empty() {
|
||||
"无标题".to_string()
|
||||
} else {
|
||||
@@ -592,14 +552,7 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
title: normalize_filetree_mindmap_title(
|
||||
raw_title,
|
||||
asset_id.as_deref(),
|
||||
&icon_kind,
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("resourceKind"))
|
||||
.and_then(Value::as_str),
|
||||
),
|
||||
title: normalize_filetree_mindmap_title(raw_title),
|
||||
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
|
||||
expandable: item
|
||||
.get("expandable")
|
||||
@@ -7336,8 +7289,10 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains("Frontmatter Title"));
|
||||
assert!(html.contains("Child H1"));
|
||||
assert!(html.contains("README"));
|
||||
assert!(html.contains("child"));
|
||||
assert!(!html.contains("Frontmatter Title"));
|
||||
assert!(!html.contains("Child H1"));
|
||||
assert!(html.contains(">docs<") || html.contains("docs"));
|
||||
assert!(!html.contains("image.png"));
|
||||
}
|
||||
@@ -7380,8 +7335,23 @@ mod tests {
|
||||
.as_str()
|
||||
.expect("document id")
|
||||
.to_string();
|
||||
assert!(root.join("新页面.md").exists());
|
||||
assert!(root.join(".mnote").join("page-ids.json").exists());
|
||||
let created_relative_path = payload["result"]["execution"]["relativePath"]
|
||||
.as_str()
|
||||
.expect("created relative path");
|
||||
let (created_dir, created_file) = created_relative_path
|
||||
.split_once('/')
|
||||
.expect("created nested bundle path");
|
||||
assert!(created_dir.starts_with("新页面"));
|
||||
assert_eq!(created_file, format!("{created_dir}.md"));
|
||||
assert!(root.join(created_dir).join(created_file).exists());
|
||||
assert_eq!(
|
||||
document_id,
|
||||
format!(
|
||||
"local-md:{}",
|
||||
crate::routes::local_folder_source::encode_local_id_segment(created_relative_path)
|
||||
)
|
||||
);
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
let rename_response = app()
|
||||
.oneshot(
|
||||
@@ -7406,8 +7376,18 @@ mod tests {
|
||||
"{}",
|
||||
String::from_utf8_lossy(&rename_body)
|
||||
);
|
||||
assert!(!root.join("新页面.md").exists());
|
||||
assert!(root.join("重命名页面.md").exists());
|
||||
assert!(!root.join(created_dir).exists());
|
||||
assert!(root.join("重命名页面").join("重命名页面.md").exists());
|
||||
let rename_payload: Value = serde_json::from_slice(&rename_body).expect("rename json");
|
||||
let renamed_document_id = rename_payload["result"]["documentId"]
|
||||
.as_str()
|
||||
.expect("renamed document id")
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
renamed_document_id,
|
||||
"local-md:~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md"
|
||||
);
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs dir");
|
||||
let move_response = app()
|
||||
@@ -7417,15 +7397,38 @@ mod tests {
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","parentId":"local-dir:docs","sortOrder":0}}"#
|
||||
r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{renamed_document_id}","parentId":"local-dir:docs","sortOrder":0}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(move_response.status(), StatusCode::OK);
|
||||
assert!(!root.join("重命名页面.md").exists());
|
||||
assert!(root.join("docs").join("重命名页面.md").exists());
|
||||
let move_status = move_response.status();
|
||||
let move_body = axum::body::to_bytes(move_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("move body");
|
||||
assert_eq!(
|
||||
move_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&move_body)
|
||||
);
|
||||
assert!(!root.join("重命名页面").exists());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
|
||||
let moved_document_id = move_payload["result"]["documentId"]
|
||||
.as_str()
|
||||
.expect("moved document id")
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
moved_document_id,
|
||||
"local-md:docs~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md"
|
||||
);
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
let copy_response = app()
|
||||
.oneshot(
|
||||
@@ -7434,7 +7437,7 @@ mod tests {
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","parentId":"local-dir:docs"}}"#
|
||||
r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}","parentId":"local-dir:docs"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7455,7 +7458,11 @@ mod tests {
|
||||
.as_str()
|
||||
.expect("copied document id")
|
||||
.to_string();
|
||||
assert!(root.join("docs").join("重命名页面 2.md").exists());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面 2")
|
||||
.join("重命名页面 2.md")
|
||||
.exists());
|
||||
|
||||
let folder_response = app()
|
||||
.oneshot(
|
||||
@@ -7480,20 +7487,22 @@ mod tests {
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}"}}"#
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||
assert!(!root.join("docs").join("重命名页面.md").exists());
|
||||
assert!(!root.join("docs").join("重命名页面").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(root.join(".mnote").join("trash-index.json").exists());
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
let restore_response = app()
|
||||
.oneshot(
|
||||
@@ -7502,14 +7511,33 @@ mod tests {
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}"}}"#
|
||||
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(restore_response.status(), StatusCode::OK);
|
||||
assert!(root.join("docs").join("重命名页面.md").exists());
|
||||
let restore_status = restore_response.status();
|
||||
let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("restore body");
|
||||
assert_eq!(
|
||||
restore_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&restore_body)
|
||||
);
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
||||
assert_eq!(
|
||||
restore_payload["result"]["documentId"].as_str(),
|
||||
Some(moved_document_id.as_str())
|
||||
);
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
let purge_response = app()
|
||||
.oneshot(
|
||||
@@ -7525,7 +7553,7 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(purge_response.status(), StatusCode::OK);
|
||||
assert!(!root.join("docs").join("重命名页面 2.md").exists());
|
||||
assert!(!root.join("docs").join("重命名页面 2").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ pub async fn document_page_shell(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
|
||||
{}
|
||||
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
@@ -228,6 +228,8 @@ pub async fn document_page_shell(
|
||||
escape_html(title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&document_id),
|
||||
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
|
||||
escape_html(primary_root_uri.unwrap_or("")),
|
||||
secondary_requested,
|
||||
secondary_invalid,
|
||||
body_content,
|
||||
@@ -557,11 +559,24 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
|
||||
}
|
||||
writeLastSavedTitle(title);
|
||||
updateVisibleTitle(input, title, currentTarget.documentId);
|
||||
const result = payload?.result || {};
|
||||
const nextDocumentId = String(result.documentId || result.id || currentTarget.documentId || '').trim();
|
||||
const previousDocumentId = currentTarget.documentId;
|
||||
const nextTitle = String(result.title || title || '无标题').trim() || '无标题';
|
||||
if (nextDocumentId) {
|
||||
input.setAttribute('data-document-id', nextDocumentId);
|
||||
}
|
||||
writeLastSavedTitle(nextTitle);
|
||||
updateVisibleTitle(input, nextTitle, nextDocumentId || currentTarget.documentId);
|
||||
setStatus(input, 'saved');
|
||||
window.dispatchEvent(new CustomEvent('tree:title-updated', {
|
||||
detail: { documentId: currentTarget.documentId, workspaceId: currentTarget.workspaceId || null, title, payload },
|
||||
detail: {
|
||||
documentId: nextDocumentId || currentTarget.documentId,
|
||||
previousDocumentId,
|
||||
workspaceId: currentTarget.workspaceId || null,
|
||||
title: nextTitle,
|
||||
payload,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
|
||||
@@ -896,6 +911,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const mindmapId = firstNonEmptyText(
|
||||
block?.props?.mindmapId,
|
||||
block?.props?.mindmap_id,
|
||||
block?.props?.sourcePath,
|
||||
block?.props?.source_path,
|
||||
block?.mindmapId,
|
||||
block?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
@@ -1023,10 +1040,84 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return textToTiptapDocument(fallbackText);
|
||||
};
|
||||
|
||||
const pageBodyTiptapDocument = (body, fallbackText = '') => {
|
||||
const decodeLocalIdSegment = (segment) => {
|
||||
const encoded = String(segment || '').replace(/~([0-9a-fA-F]{2})/g, '%$1');
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
} catch (_) {
|
||||
return String(segment || '').replace(/~2F/g, '/').replace(/~20/g, ' ');
|
||||
}
|
||||
};
|
||||
|
||||
const localMarkdownRelativePathFromDocumentId = (documentId) => {
|
||||
const raw = String(documentId || '').trim();
|
||||
const segment = raw.startsWith('local-md:') ? raw.slice('local-md:'.length) : raw;
|
||||
return decodeLocalIdSegment(segment).replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
const localMarkdownDirectoryFromDocumentId = (documentId) => {
|
||||
const relativePath = localMarkdownRelativePathFromDocumentId(documentId);
|
||||
const slash = relativePath.lastIndexOf('/');
|
||||
return slash >= 0 ? relativePath.slice(0, slash) : '';
|
||||
};
|
||||
|
||||
const isExternalOrSpecialUrl = (value) => {
|
||||
const text = String(value || '').trim();
|
||||
return !text
|
||||
|| text.startsWith('#')
|
||||
|| text.startsWith('data:')
|
||||
|| text.startsWith('blob:')
|
||||
|| text.startsWith('mailto:')
|
||||
|| text.startsWith('http://')
|
||||
|| text.startsWith('https://')
|
||||
|| text.startsWith('/api/');
|
||||
};
|
||||
|
||||
const normalizeLocalAssetRelativePath = (value, context) => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || isExternalOrSpecialUrl(text)) return text;
|
||||
if (text.startsWith('/')) return text.replace(/^\/+/, '');
|
||||
const baseDir = localMarkdownDirectoryFromDocumentId(context?.documentId);
|
||||
return (baseDir ? `${baseDir}/${text}` : text)
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.')
|
||||
.join('/');
|
||||
};
|
||||
|
||||
const localFileOpenUrlForTiptap = (value, context) => {
|
||||
if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value;
|
||||
const relativePath = normalizeLocalAssetRelativePath(value, context);
|
||||
if (!relativePath || isExternalOrSpecialUrl(relativePath)) return value;
|
||||
const url = new URL('/api/local-folder/files/open', window.location.origin);
|
||||
url.searchParams.set('rootUri', context.rootUri);
|
||||
url.searchParams.set('path', relativePath);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const localizeTiptapAssetUrls = (node, context) => {
|
||||
if (!node || typeof node !== 'object') return node;
|
||||
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
|
||||
node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) };
|
||||
}
|
||||
if (Array.isArray(node.marks)) {
|
||||
node.marks = node.marks.map((mark) => {
|
||||
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
|
||||
return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } };
|
||||
});
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context));
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context);
|
||||
}
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return toTiptapDocument(blockDocument, fallbackText);
|
||||
return toTiptapDocument(body?.content, fallbackText);
|
||||
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context);
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context);
|
||||
};
|
||||
|
||||
const inlineTextNodes = (node) => {
|
||||
@@ -1089,6 +1180,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const mindmapId = firstNonEmptyText(
|
||||
attrs?.mindmapId,
|
||||
attrs?.mindmap_id,
|
||||
attrs?.sourcePath,
|
||||
attrs?.source_path,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id,
|
||||
@@ -1106,10 +1199,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const tiptapNodeToEditorBlock = (node, index) => {
|
||||
const blockId = blockIdOf(node, index);
|
||||
if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') {
|
||||
const mindmapId = firstNonEmptyText(
|
||||
node?.attrs?.mindmapId,
|
||||
node?.attrs?.mindmap_id,
|
||||
node?.attrs?.sourcePath,
|
||||
node?.attrs?.source_path,
|
||||
blockId
|
||||
);
|
||||
return {
|
||||
blockId,
|
||||
blockType: 'mindmap',
|
||||
props: mindmapPropsFromAttrs(node?.attrs, blockId),
|
||||
props: {
|
||||
...mindmapPropsFromAttrs(node?.attrs, blockId),
|
||||
sourcePath: mindmapId,
|
||||
},
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
};
|
||||
@@ -1577,6 +1680,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
|
||||
if (!session || session.sourceKind !== 'local_folder') return false;
|
||||
if (!sessionHasRecentExternalSignal(session)) return false;
|
||||
if (!sessionPlainText(session)) return false;
|
||||
return !aggregatePlainText(nextAggregate);
|
||||
};
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const conflictEnvelopeFromResponse = (payload) => (
|
||||
payload?.error?.details?.conflict
|
||||
|| payload?.details?.conflict
|
||||
@@ -1610,7 +1722,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
@@ -1937,6 +2049,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const scheduleSessionExternalRefresh = (session, source) => {
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.externalRefreshTimer) return;
|
||||
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
|
||||
session.externalRefreshTimer = window.setTimeout(() => {
|
||||
@@ -1949,6 +2062,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const refreshSessionFromExternalChange = async (session, source) => {
|
||||
if (document.hidden) return;
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
|
||||
try {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
@@ -1973,11 +2087,26 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
|
||||
return;
|
||||
}
|
||||
const nextAggregate = payload?.result;
|
||||
let nextAggregate = payload?.result;
|
||||
if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) {
|
||||
await delay(500);
|
||||
try {
|
||||
const retryAggregate = await fetchLatestSessionAggregate(session);
|
||||
if (aggregatePlainText(retryAggregate)) {
|
||||
nextAggregate = retryAggregate;
|
||||
} else {
|
||||
markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。');
|
||||
return;
|
||||
}
|
||||
} catch (_retryError) {
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
@@ -2036,11 +2165,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const payload = parseLocalFolderEventPayload(event);
|
||||
if (!payload) return;
|
||||
Array.from(channel.sessions.values()).forEach((targetSession) => {
|
||||
if (!targetSession || targetSession.views.size === 0) return;
|
||||
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
|
||||
const eventKind = String(payload.eventKind || '');
|
||||
const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name');
|
||||
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
|
||||
if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return;
|
||||
if (targetsCurrentDocument && targetSession.saving) {
|
||||
targetSession.externalChangePending = false;
|
||||
targetSession.lastSelfSaveSignalAt = Date.now();
|
||||
return;
|
||||
}
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
|
||||
@@ -2256,8 +2391,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
|
||||
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
|
||||
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody);
|
||||
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
|
||||
const session = {
|
||||
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
|
||||
documentId: runtimeDescriptor.bootstrap.documentId,
|
||||
@@ -2282,6 +2417,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
externalChangePending: false,
|
||||
externalRefreshSource: '',
|
||||
lastExternalChangeSignalAt: 0,
|
||||
lastSelfSaveSignalAt: 0,
|
||||
lastUserInputAt: 0,
|
||||
status: 'booting',
|
||||
error: null,
|
||||
@@ -3498,10 +3634,16 @@ mod tests {
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||
assert!(html.contains("const pageBodyTiptapDocument = (body, fallbackText = '') => {"));
|
||||
assert!(html.contains(
|
||||
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
|
||||
));
|
||||
assert!(html.contains("body?.blockDocument || body?.block_document"));
|
||||
assert!(html.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody);"));
|
||||
assert!(html.contains("const tiptapDocument = pageBodyTiptapDocument(pageBody);"));
|
||||
assert!(html.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content"));
|
||||
assert!(html
|
||||
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
|
||||
assert!(html.contains(
|
||||
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
|
||||
));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
@@ -3655,10 +3797,10 @@ mod tests {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
|
||||
root.join("Local Aggregate").join("Local Aggregate.md"),
|
||||
"# Local Heading\n正文内容\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
@@ -3668,7 +3810,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
@@ -3695,7 +3837,7 @@ mod tests {
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(
|
||||
payload["result"]["identity"]["documentId"],
|
||||
"local-md:README.md"
|
||||
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
|
||||
);
|
||||
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
||||
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
||||
@@ -3710,10 +3852,19 @@ mod tests {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
|
||||
.expect("write child md");
|
||||
std::fs::create_dir_all(root.join("Local Shell")).expect("create local page bundle");
|
||||
std::fs::create_dir_all(root.join("docs").join("Child Page"))
|
||||
.expect("create local child bundle");
|
||||
std::fs::write(
|
||||
root.join("Local Shell").join("Local Shell.md"),
|
||||
"# Local Shell\n正文\n",
|
||||
)
|
||||
.expect("write root md");
|
||||
std::fs::write(
|
||||
root.join("docs").join("Child Page").join("Child Page.md"),
|
||||
"# Child Page\n",
|
||||
)
|
||||
.expect("write child md");
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
@@ -3722,7 +3873,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
@@ -3771,6 +3922,10 @@ mod tests {
|
||||
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
|
||||
assert!(html.contains("mayAffectMissingDocument"));
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("targetSession.views.size === 0"));
|
||||
assert!(html.contains("session.views.size === 0"));
|
||||
assert!(html.contains("targetSession.saving"));
|
||||
assert!(html.contains("lastSelfSaveSignalAt"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
assert!(html.contains("mnote-editor-conflict-panel"));
|
||||
@@ -3875,6 +4030,8 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Spec"));
|
||||
assert!(html.contains("assets/spec.pdf"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -191,6 +191,9 @@ pub fn DocumentPage(
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
/// 是否启用树实时流
|
||||
#[prop(optional, default = true)]
|
||||
enable_tree_live: bool,
|
||||
) -> impl IntoView {
|
||||
let has_page_subtree = page_subtree_json
|
||||
.as_deref()
|
||||
@@ -288,7 +291,7 @@ pub fn DocumentPage(
|
||||
visible: secondary_visible,
|
||||
};
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} show_admin_access_policy={show_admin_access_policy}>
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
|
||||
<div
|
||||
class="document-workspace"
|
||||
data-testid="mnote-document-workspace"
|
||||
|
||||
@@ -37,13 +37,13 @@ pub fn HomePage(
|
||||
let workspace_id = workspace_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let workspace_id_value = workspace_id.clone().unwrap_or_default();
|
||||
let active_href = workspace_id
|
||||
.as_deref()
|
||||
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
|
||||
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
|
||||
let enable_tree_live = false;
|
||||
view! {
|
||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy}>
|
||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
|
||||
{move || if has_active_page {
|
||||
view! {
|
||||
<main class="document-shell document-shell--workspace-entry" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
|
||||
@@ -81,8 +81,7 @@ pub fn HomePage(
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
data-testid="mnote-empty-create-page"
|
||||
data-mnote-action="create-page"
|
||||
data-workspace-id={workspace_id_value.clone()}
|
||||
data-mnote-action="create-local-workspace"
|
||||
>"新建页面"</button>
|
||||
</section>
|
||||
}.into_any()
|
||||
|
||||
@@ -625,10 +625,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function currentWorkspaceId() {
|
||||
return resolveWorkspaceId(document.body);
|
||||
}
|
||||
|
||||
function currentSourceKind() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = (params.get('sourceKind') || '').trim();
|
||||
if (fromUrl) return fromUrl;
|
||||
var fromBody = document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-source-kind') || '').trim() : '';
|
||||
if (fromBody) return fromBody;
|
||||
if (
|
||||
window.location.pathname === '/' &&
|
||||
!(params.get('workspaceId') || '').trim() &&
|
||||
@@ -641,7 +647,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function currentRootUri() {
|
||||
return (new URLSearchParams(window.location.search).get('rootUri') || '').trim();
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = (params.get('rootUri') || '').trim();
|
||||
if (fromUrl) return fromUrl;
|
||||
return document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '';
|
||||
}
|
||||
|
||||
function rememberCloudWorkspaceId(workspaceId) {
|
||||
@@ -691,6 +700,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) targetUrl.searchParams.set(name, value);
|
||||
});
|
||||
var sourceKind = currentSourceKind();
|
||||
var rootUri = currentRootUri();
|
||||
if (sourceKind && !targetUrl.searchParams.get('sourceKind')) targetUrl.searchParams.set('sourceKind', sourceKind);
|
||||
if (rootUri && !targetUrl.searchParams.get('rootUri')) targetUrl.searchParams.set('rootUri', rootUri);
|
||||
}
|
||||
|
||||
function currentWorkspaceSourcePayload() {
|
||||
@@ -700,6 +713,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) payload[name] = value;
|
||||
});
|
||||
var sourceKind = currentSourceKind();
|
||||
var rootUri = currentRootUri();
|
||||
if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind;
|
||||
if (rootUri && !payload.rootUri) payload.rootUri = rootUri;
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -838,13 +855,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
document.documentElement.setAttribute('data-mnote-trash-modal-open', 'true');
|
||||
var content = overlay.querySelector('[data-testid="mnote-trash-modal-content"]');
|
||||
var sourceKind = currentSourceKind();
|
||||
if (sourceKind === 'local_folder') {
|
||||
renderLocalFolderTrashPlaceholder(content, new URLSearchParams(window.location.search).get('rootUri') || '');
|
||||
return;
|
||||
}
|
||||
var workspaceId = resolveWorkspaceId(trigger || document.body);
|
||||
var url = new URL('/trash', window.location.origin);
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
if (sourceKind === 'local_folder') {
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) {
|
||||
renderLocalFolderTrashPlaceholder(content, '');
|
||||
return;
|
||||
}
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
} else if (workspaceId) {
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
}
|
||||
fetch(url.toString(), { headers: { 'x-mnote-trash-modal': '1' } }).then(function(response) {
|
||||
return response.text().then(function(html) {
|
||||
if (!response.ok) throw new Error('trash_modal_load_failed_' + response.status);
|
||||
@@ -1294,7 +1317,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
title: '新页面'
|
||||
});
|
||||
var nextWorkspaceId = result.workspaceId || workspaceId;
|
||||
navigateToDocument(result.documentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() });
|
||||
navigateToDocument(commandDocumentId(result, ''), nextWorkspaceId, { treeView: activeSidebarTreeMode() });
|
||||
}
|
||||
|
||||
function applySidebarTreeTab(mode, shell) {
|
||||
@@ -1450,6 +1473,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function commandDocumentId(result, fallback) {
|
||||
var value = result && (
|
||||
(result.execution && (result.execution.documentId || result.execution.id || result.execution.nodeId)) ||
|
||||
result.documentId ||
|
||||
result.id ||
|
||||
result.nodeId ||
|
||||
@@ -1462,6 +1486,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function commandDocumentTitle(result, fallback) {
|
||||
var value = result && (
|
||||
(result.execution && result.execution.title) ||
|
||||
result.title ||
|
||||
(result.document && result.document.title) ||
|
||||
(result.node && result.node.title) ||
|
||||
@@ -1581,6 +1606,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return pageChanged || fileChanged;
|
||||
}
|
||||
|
||||
function localCommandNeedsProjectionRefresh(action, result) {
|
||||
if (currentSourceKind() !== 'local_folder') return false;
|
||||
if (action === 'create' || action === 'rename' || action === 'move' || action === 'delete' || action === 'archive' || action === 'trash' || action === 'purge') {
|
||||
return true;
|
||||
}
|
||||
if (result && (result.previousDocumentId || result.previousRelativePath || result.relativePath || result.trashPath)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sortOrderFromDelta(data) {
|
||||
var raw = data && (data.sortOrder ?? data.sort_order);
|
||||
var value = Number(raw);
|
||||
@@ -1798,9 +1834,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity) {
|
||||
var title = String(rawTitle || '').trim();
|
||||
var isMindmap = iconKind === 'mindmap' || String(objectIdentity && objectIdentity.objectKind || '') === 'mindmap';
|
||||
var generated = /^mindmap[-_]/i.test(title);
|
||||
if (!isMindmap || (!generated && title.length <= 24)) return title || '无标题';
|
||||
return shortMindmapFileName(assetId || title);
|
||||
if (!isMindmap) return title || '无标题';
|
||||
return title || shortMindmapFileName(assetId);
|
||||
}
|
||||
|
||||
function renderFileRows(parentId, grouped, activeId, activeRowId) {
|
||||
@@ -1865,9 +1900,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
async function refreshLocalFolderSidebarSnapshot() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var workspaceId = currentWorkspaceId();
|
||||
var rootUri = (params.get('rootUri') || '').trim();
|
||||
var rootUri = currentRootUri();
|
||||
var currentId = currentDocumentId();
|
||||
if (!workspaceId || !rootUri) return false;
|
||||
|
||||
@@ -1900,9 +1934,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function startLocalFolderSidebarWatch() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if ((params.get('sourceKind') || '').trim() !== 'local_folder') return;
|
||||
var rootUri = (params.get('rootUri') || '').trim();
|
||||
if (currentSourceKind() !== 'local_folder') return;
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) return;
|
||||
var revision = '';
|
||||
var refreshTimer = 0;
|
||||
@@ -2029,11 +2062,29 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '/onlyoffice?' + params.toString();
|
||||
}
|
||||
|
||||
function buildLocalOnlyOfficeOpenUrl(relativePath, fileName, documentId, assetId) {
|
||||
var fileType = inferOnlyOfficeFileType(fileName || relativePath || '', '');
|
||||
if (!fileType) return '';
|
||||
var fileUrl = buildLocalFileOpenUrl(relativePath, false);
|
||||
if (!fileUrl) return '';
|
||||
return buildOnlyOfficeOpenUrl({
|
||||
fileUrl: fileUrl,
|
||||
fileName: fileName || relativePath || '未命名资源',
|
||||
fileType: fileType,
|
||||
assetId: assetId || ('local-file:' + relativePath),
|
||||
documentId: documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: 'edit'
|
||||
});
|
||||
}
|
||||
|
||||
function buildMindmapOpenPath(documentId, assetId) {
|
||||
var doc = String(documentId || '').trim();
|
||||
var map = String(assetId || '').trim();
|
||||
if (!doc || !map) return '';
|
||||
return '/mindmap/' + encodeURIComponent(doc) + '/' + encodeURIComponent(map);
|
||||
var targetUrl = new URL('/mindmap/' + encodeURIComponent(doc) + '/' + encodeURIComponent(map), window.location.origin);
|
||||
copyWorkspaceSourceParams(targetUrl);
|
||||
return targetUrl.pathname + targetUrl.search;
|
||||
}
|
||||
|
||||
function navigateToMindmapObject(documentId, assetId, workspaceId) {
|
||||
@@ -2077,7 +2128,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
var assetType = String(detail && detail.assetType || '').trim();
|
||||
if (assetType === 'mindmap') return true;
|
||||
return assetId.indexOf('mindmap_') === 0 || assetId.indexOf('mindmap-') === 0;
|
||||
var fileName = assetId.indexOf('/') >= 0 ? assetId.split('/').pop() : assetId;
|
||||
return assetId.indexOf('mindmap_') === 0
|
||||
|| assetId.indexOf('mindmap-') === 0
|
||||
|| /\.mindmap\.json$/i.test(fileName)
|
||||
|| (/^思维导图/i.test(fileName) && /\.json$/i.test(fileName));
|
||||
}
|
||||
|
||||
function localFilePathFromAssetId(assetId) {
|
||||
@@ -2126,6 +2181,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!assetId) return;
|
||||
var localFilePath = localFilePathFromAssetId(assetId);
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
if (isMindmapAssetDetail(detail) || String(detail && detail.assetType || '').trim() === 'mindmap') {
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell');
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
||||
return;
|
||||
}
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId);
|
||||
if (localOfficeUrl) {
|
||||
window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
if (localFileUrl) window.open(localFileUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
@@ -2267,6 +2334,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
||||
}
|
||||
|
||||
function localAssetOpenUrl(asset, download) {
|
||||
if (!isLocalUploadedAsset(asset)) return '';
|
||||
var rootUri = String(asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri();
|
||||
var rootRelativePath = String(asset && (asset.rootRelativePath || asset.root_relative_path) || '').trim();
|
||||
if (!rootUri || !rootRelativePath) return '';
|
||||
var url = new URL('/api/local-folder/files/open', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('path', rootRelativePath);
|
||||
if (download) url.searchParams.set('download', 'true');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function uploadedAssetType(asset) {
|
||||
return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim();
|
||||
}
|
||||
@@ -2360,11 +2439,23 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function buildOnlyOfficeAssetOpenUrl(asset, userId) {
|
||||
if (isLocalUploadedAsset(asset)) return '';
|
||||
var title = uploadedAssetTitle(asset);
|
||||
var fileType = inferOnlyOfficeFileType(title, asset && asset.mime_type);
|
||||
if (!fileType) return '';
|
||||
var assetId = String(asset && asset.id || '').trim();
|
||||
if (isLocalUploadedAsset(asset)) {
|
||||
var localUrl = localAssetOpenUrl(asset, false);
|
||||
if (!localUrl) return '';
|
||||
return buildOnlyOfficeOpenUrl({
|
||||
fileUrl: localUrl,
|
||||
fileName: title,
|
||||
fileType: fileType,
|
||||
assetId: assetId,
|
||||
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||
userId: userId || '',
|
||||
mode: 'edit'
|
||||
});
|
||||
}
|
||||
return buildOnlyOfficeOpenUrl({
|
||||
fileUrl: assetId ? '' : uploadedAssetUrl(asset),
|
||||
fileName: title,
|
||||
@@ -2402,14 +2493,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
legacyOfficeAttachmentIndex = Object.create(null);
|
||||
return legacyOfficeAttachmentIndex;
|
||||
}
|
||||
legacyOfficeAttachmentIndexPending = fetch(
|
||||
'/api/tree/projections/file?documentId=' + encodeURIComponent(documentId) + '&workspaceId=' + encodeURIComponent(workspaceId),
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
}
|
||||
).then(function(response) {
|
||||
var projectionUrl = new URL('/api/tree/projections/file', window.location.origin);
|
||||
projectionUrl.searchParams.set('documentId', documentId);
|
||||
projectionUrl.searchParams.set('workspaceId', workspaceId);
|
||||
var sourcePayload = currentWorkspaceSourcePayload();
|
||||
if (sourcePayload.sourceKind) projectionUrl.searchParams.set('sourceKind', sourcePayload.sourceKind);
|
||||
if (sourcePayload.rootUri) projectionUrl.searchParams.set('rootUri', sourcePayload.rootUri);
|
||||
legacyOfficeAttachmentIndexPending = fetch(projectionUrl.toString(), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
}).then(function(response) {
|
||||
return response.json().catch(function() { return null; }).then(function(payload) {
|
||||
var items = payload && payload.ok && payload.result && Array.isArray(payload.result.items)
|
||||
? payload.result.items
|
||||
@@ -2581,11 +2675,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var container = children || root;
|
||||
var li = document.createElement('li');
|
||||
li.className = 'tree-node';
|
||||
var objectKind = String(asset && (asset.objectKind || asset.resourceKind || '') || '').trim();
|
||||
if (!objectKind && (String(asset && (asset.asset_type || asset.assetType) || '').trim() === 'mindmap' || /\.mindmap\.json$/i.test(assetId))) {
|
||||
objectKind = 'mindmap';
|
||||
}
|
||||
var objectIdentity = {
|
||||
objectKind: objectKind || 'attachment',
|
||||
documentId: targetDocumentId || null,
|
||||
blockId: null,
|
||||
assetId: assetId
|
||||
};
|
||||
li.setAttribute('data-node-id', 'asset:' + assetId);
|
||||
var title = uploadedAssetTitle(asset);
|
||||
var iconKind = uploadedAssetType(asset) || 'file';
|
||||
if (objectKind === 'mindmap') iconKind = 'mindmap';
|
||||
li.innerHTML =
|
||||
'<div class="tree-row" role="treeitem" aria-level="' + (parentRow ? '2' : '1') + '" aria-expanded="false" data-rust-rendered-row="filetree" data-testid="filetree-asset-row" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-row-kind="asset" data-node-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-doc-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="false" data-active="false" draggable="true">' +
|
||||
'<div class="tree-row" role="treeitem" aria-level="' + (parentRow ? '2' : '1') + '" aria-expanded="false" data-rust-rendered-row="filetree" data-testid="filetree-asset-row" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-row-kind="asset" data-node-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-doc-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(objectKind || 'attachment') + '" data-shell-mode="filetree" data-selected="false" data-active="false" draggable="true">' +
|
||||
'<span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span>' +
|
||||
'<button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button>' +
|
||||
'<div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml('asset:' + assetId) + '" aria-label="更多操作">…</button></div></div>';
|
||||
@@ -2624,25 +2729,35 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function mindmapAssetFromTarget(documentId, mindmapId) {
|
||||
function mindmapAssetFromTarget(documentId, mindmapId, writeResult) {
|
||||
var docId = String(documentId || currentDocumentId() || '').trim();
|
||||
var assetId = String(mindmapId || '').trim();
|
||||
if (!docId || !assetId) return null;
|
||||
var fileName = shortMindmapFileName(assetId);
|
||||
var result = writeResult && typeof writeResult === 'object' ? writeResult : {};
|
||||
var resultAssetId = String(result.assetId || result.id || '').trim();
|
||||
var relativePath = String(result.relativePath || result.rootRelativePath || '').trim();
|
||||
var fileName = String(result.fileName || '').trim() || shortMindmapFileName(assetId);
|
||||
return {
|
||||
id: assetId,
|
||||
id: resultAssetId || (relativePath ? 'local-file:' + relativePath : assetId),
|
||||
document_id: docId,
|
||||
asset_type: 'mindmap',
|
||||
file_name: fileName,
|
||||
file_url: '/documents/' + encodeURIComponent(docId) + '/' + encodeURIComponent(fileName)
|
||||
file_url: relativePath || ('/documents/' + encodeURIComponent(docId) + '/' + encodeURIComponent(fileName)),
|
||||
sourcePath: relativePath,
|
||||
rootRelativePath: relativePath,
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
objectKind: 'mindmap'
|
||||
};
|
||||
}
|
||||
|
||||
function shortMindmapFileName(mindmapId) {
|
||||
var raw = String(mindmapId || '').trim();
|
||||
var pathName = raw.indexOf('/') >= 0 ? raw.split('/').pop() : raw;
|
||||
if (/\.json$/i.test(pathName)) return pathName;
|
||||
var digits = raw.match(/(\d{4,})$/);
|
||||
var suffix = digits ? digits[1].slice(-4) : raw.replace(/^mindmap[_-]?/i, '').slice(-6);
|
||||
return suffix ? '思维导图-' + suffix + '.json' : '思维导图.json';
|
||||
var suffix = digits ? digits[1].slice(-6) : '';
|
||||
return suffix ? '思维导图' + suffix + '.json' : '思维导图.json';
|
||||
}
|
||||
|
||||
function parseMindmapApiTarget(input) {
|
||||
@@ -2665,6 +2780,27 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function withLocalMindmapSourceParams(input) {
|
||||
var target = parseMindmapApiTarget(input);
|
||||
if (!target || currentSourceKind() !== 'local_folder') return input;
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) return input;
|
||||
var rawUrl = typeof input === 'string'
|
||||
? input
|
||||
: input && typeof input.url === 'string'
|
||||
? input.url
|
||||
: '';
|
||||
if (!rawUrl) return input;
|
||||
var url = new URL(rawUrl, window.location.origin);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
if (typeof input === 'string') return url.pathname + url.search;
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
return new Request(url.toString(), input);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function requestMethod(input, init) {
|
||||
return String(
|
||||
init && init.method
|
||||
@@ -2686,13 +2822,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function applyMindmapApiMutationToFileTree(target) {
|
||||
function applyMindmapApiMutationToFileTree(target, payload) {
|
||||
if (!target || !target.documentId || !target.mindmapId) return;
|
||||
var asset = mindmapAssetFromTarget(target.documentId, target.mindmapId);
|
||||
var asset = mindmapAssetFromTarget(target.documentId, target.mindmapId, payload && payload.writeResult);
|
||||
if (!asset) return;
|
||||
appendUploadedAssetRow(asset, target.documentId);
|
||||
document.documentElement.setAttribute('data-mnote-assets-local-applied', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', target.mindmapId);
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', asset.id || target.mindmapId);
|
||||
}
|
||||
|
||||
function installMindmapAssetFetchObserver() {
|
||||
@@ -2701,26 +2837,72 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.__mnoteMindmapAssetFetchObserverInstalled = true;
|
||||
var originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function(input, init) {
|
||||
var target = parseMindmapApiTarget(input);
|
||||
var nextInput = withLocalMindmapSourceParams(input);
|
||||
var target = parseMindmapApiTarget(nextInput);
|
||||
var method = requestMethod(input, init);
|
||||
var createOnly = requestBodyHasMindmapCreateOnly(init || {});
|
||||
return originalFetch(input, init).then(function(response) {
|
||||
return originalFetch(nextInput, init).then(function(response) {
|
||||
if (target && method === 'POST' && response && response.ok) {
|
||||
if (createOnly || target.documentId === currentDocumentId()) {
|
||||
applyMindmapApiMutationToFileTree(target);
|
||||
}
|
||||
var cloned = response.clone();
|
||||
void cloned.json().then(function(payload) {
|
||||
if (createOnly || target.documentId === currentDocumentId()) {
|
||||
applyMindmapApiMutationToFileTree(target, payload);
|
||||
}
|
||||
}).catch(function() {
|
||||
if (createOnly || target.documentId === currentDocumentId()) {
|
||||
applyMindmapApiMutationToFileTree(target, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function archiveLocalFileTreeAsset(row, assetId) {
|
||||
if (!assetId) return false;
|
||||
await dispatchTreeCommand(row || document.body, {
|
||||
action: 'archive',
|
||||
workspaceId: resolveWorkspaceId(row || document.body),
|
||||
documentId: assetId
|
||||
});
|
||||
removeFileTreeAssetRow(assetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deleteSingleFileTreeAsset(detail, trigger) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return false;
|
||||
var row = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : null;
|
||||
if (!row) row = document.querySelector('.tree-row[data-shell-mode="filetree"][data-asset-id="' + cssEscape(assetId) + '"]');
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
return archiveLocalFileTreeAsset(row, assetId);
|
||||
}
|
||||
var documentId = String(detail && detail.documentId || '').trim();
|
||||
var kind = classifySidebarFileTreeAsset(row);
|
||||
if (kind === 'mindmap') {
|
||||
var response = await fetch('/api/mindmap/' + encodeURIComponent(documentId) + '/' + encodeURIComponent(assetId), { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('mindmap_delete_failed_' + response.status);
|
||||
removeFileTreeAssetRow(assetId);
|
||||
return true;
|
||||
}
|
||||
if (kind === 'table') {
|
||||
var tableResponse = await fetch('/api/tables/' + encodeURIComponent(assetId), { method: 'DELETE' });
|
||||
if (!tableResponse.ok) throw new Error('table_delete_failed_' + tableResponse.status);
|
||||
removeFileTreeAssetRow(assetId);
|
||||
return true;
|
||||
}
|
||||
await postSidebarFileTreeJson('/api/media/batch', { action: 'delete', assetIds: [assetId] });
|
||||
removeFileTreeAssetRow(assetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function insertUploadedAssetIntoEditor(asset) {
|
||||
var editorRoot = document.querySelector('.editor-surface .ProseMirror');
|
||||
var editor = editorRoot && editorRoot.editor;
|
||||
if (!editor || !editor.chain) return false;
|
||||
var title = uploadedAssetTitle(asset);
|
||||
var url = uploadedAssetUrl(asset);
|
||||
var url = localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset);
|
||||
var type = uploadedAssetType(asset);
|
||||
var assetId = String(asset && asset.id || '').trim();
|
||||
var sizeLabel = uploadedFileSize(asset);
|
||||
@@ -2730,7 +2912,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
var isLocalAsset = isLocalUploadedAsset(asset);
|
||||
var userId = '';
|
||||
var onlyOfficeUrl = isLocalAsset ? '' : buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||
var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||
if (onlyOfficeUrl && assetId) {
|
||||
userId = await fetchCurrentOnlyOfficeUserId();
|
||||
onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||
@@ -2738,7 +2920,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var href = onlyOfficeUrl || url;
|
||||
if (href) {
|
||||
var storedHref = onlyOfficeUrl
|
||||
? buildOnlyOfficeOpenPath({
|
||||
? (isLocalAsset ? onlyOfficeUrl : buildOnlyOfficeOpenPath({
|
||||
fileUrl: '',
|
||||
fileName: title,
|
||||
fileType: inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx',
|
||||
@@ -2746,7 +2928,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||
userId: userId || '',
|
||||
mode: 'edit'
|
||||
})
|
||||
}))
|
||||
: href;
|
||||
var inserted = editor.chain().focus().insertContent({
|
||||
type: 'paragraph',
|
||||
@@ -3138,6 +3320,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var documentId = detail.documentId || '';
|
||||
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
|
||||
var title = detail.title || '无标题';
|
||||
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
if (action === 'open-right') {
|
||||
dispatchSidebarEvent('tree.page.open-right', detail);
|
||||
return;
|
||||
@@ -3166,6 +3349,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
void copyTreeContextValue(documentId || detail.assetId || detail.rowId || '', 'copy-id');
|
||||
return;
|
||||
}
|
||||
if (action === 'delete-trash' && isAsset) {
|
||||
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
|
||||
void deleteSingleFileTreeAsset(detail, trigger).catch(function(error) {
|
||||
window.alert(error && error.message ? error.message : '资源删除失败');
|
||||
}).then(function() {
|
||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'new-file') {
|
||||
void createPage(trigger || document.body, documentId || null);
|
||||
return;
|
||||
@@ -3224,12 +3416,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
convertToPreviousSiblingChild(trigger, detail);
|
||||
return;
|
||||
}
|
||||
if (action === 'delete-trash' && documentId) {
|
||||
var deleteTargetId = documentId || String(detail.rowId || '').trim();
|
||||
if (action === 'delete-trash' && deleteTargetId) {
|
||||
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
|
||||
void dispatchTreeCommand(trigger || document.body, {
|
||||
action: 'archive',
|
||||
workspaceId: workspaceId,
|
||||
documentId: documentId
|
||||
documentId: deleteTargetId
|
||||
}).then(function() {
|
||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||
}).catch(function(error) {
|
||||
window.alert(error && error.message ? error.message : '删除失败');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3303,6 +3500,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
{ separator: true },
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
@@ -3499,6 +3697,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function buildSidebarFileTreeDeletePlan(rows) {
|
||||
var docRows = [];
|
||||
var folderRows = [];
|
||||
var assetRows = [];
|
||||
rows.forEach(function(row) {
|
||||
var kind = fileTreeRowKind(row);
|
||||
@@ -3506,19 +3705,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
docRows.push(row);
|
||||
return;
|
||||
}
|
||||
if (kind === 'folder' && currentSourceKind() === 'local_folder') {
|
||||
folderRows.push(row);
|
||||
return;
|
||||
}
|
||||
if (fileTreeRowAssetId(row)) assetRows.push(row);
|
||||
});
|
||||
var selectedDocRowIds = new Set(docRows.map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean));
|
||||
var selectedContainerRowIds = new Set(docRows.concat(folderRows).map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean));
|
||||
var topDocRows = [];
|
||||
var topFolderRows = [];
|
||||
var seenDocs = new Set();
|
||||
var seenFolders = new Set();
|
||||
docRows.forEach(function(row) {
|
||||
var documentId = fileTreeRowDocumentId(row);
|
||||
if (!documentId || seenDocs.has(documentId)) return;
|
||||
if (hasSelectedDocumentAncestor(row, selectedDocRowIds)) return;
|
||||
if (hasSelectedDocumentAncestor(row, selectedContainerRowIds)) return;
|
||||
seenDocs.add(documentId);
|
||||
topDocRows.push(row);
|
||||
});
|
||||
var selectedTopDocRows = new Set(topDocRows.map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean));
|
||||
folderRows.forEach(function(row) {
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
if (!rowId || seenFolders.has(rowId)) return;
|
||||
if (hasSelectedDocumentAncestor(row, selectedContainerRowIds)) return;
|
||||
seenFolders.add(rowId);
|
||||
topFolderRows.push(row);
|
||||
});
|
||||
var selectedTopContainerRows = new Set(topDocRows.concat(topFolderRows).map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean));
|
||||
var fileAssetRows = [];
|
||||
var mindmapRows = [];
|
||||
var tableRows = [];
|
||||
@@ -3526,7 +3738,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetRows.forEach(function(row) {
|
||||
var assetId = fileTreeRowAssetId(row);
|
||||
if (!assetId || seenAssets.has(assetId)) return;
|
||||
if (hasSelectedDocumentAncestor(row, selectedTopDocRows)) return;
|
||||
if (hasSelectedDocumentAncestor(row, selectedTopContainerRows)) return;
|
||||
seenAssets.add(assetId);
|
||||
var assetKind = classifySidebarFileTreeAsset(row);
|
||||
if (assetKind === 'mindmap') mindmapRows.push(row);
|
||||
@@ -3535,6 +3747,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
return {
|
||||
docRows: topDocRows,
|
||||
folderRows: topFolderRows,
|
||||
fileAssetRows: fileAssetRows,
|
||||
mindmapRows: mindmapRows,
|
||||
tableRows: tableRows
|
||||
@@ -3543,11 +3756,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function sidebarFileTreeDeleteConfirmText(plan) {
|
||||
var docCount = plan.docRows.length;
|
||||
var folderCount = plan.folderRows.length;
|
||||
var fileCount = plan.fileAssetRows.length;
|
||||
var mindmapCount = plan.mindmapRows.length;
|
||||
var tableCount = plan.tableRows.length;
|
||||
var parts = [];
|
||||
if (docCount > 0) parts.push(docCount + ' 个页面(删除到垃圾桶)');
|
||||
if (folderCount > 0) parts.push(folderCount + ' 个文件夹(删除到垃圾桶)');
|
||||
if (fileCount > 0) parts.push(fileCount + ' 个附件(删除,10 分钟内可撤销)');
|
||||
if (mindmapCount > 0) parts.push(mindmapCount + ' 个思维导图(移入垃圾桶,10 分钟内可恢复)');
|
||||
if (tableCount > 0) parts.push(tableCount + ' 个在线表格(删除)');
|
||||
@@ -3638,7 +3853,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function deleteSelectedSidebarFileTreeRows(trigger) {
|
||||
var rows = selectedSidebarFileTreeRows();
|
||||
var plan = buildSidebarFileTreeDeletePlan(rows);
|
||||
var total = plan.docRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
|
||||
var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
|
||||
if (total === 0) return false;
|
||||
if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false;
|
||||
var failures = [];
|
||||
@@ -3656,6 +3871,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
failures.push(documentId);
|
||||
}
|
||||
}
|
||||
for (var fd = 0; fd < plan.folderRows.length; fd += 1) {
|
||||
var folderRow = plan.folderRows[fd];
|
||||
var folderId = folderRow.getAttribute('data-row-id') || folderRow.getAttribute('data-node-id') || '';
|
||||
try {
|
||||
await dispatchTreeCommand(trigger || folderRow, {
|
||||
action: 'archive',
|
||||
workspaceId: resolveWorkspaceId(folderRow),
|
||||
documentId: folderId
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(folderId);
|
||||
}
|
||||
}
|
||||
if (plan.fileAssetRows.length > 0) {
|
||||
var fileAssetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean);
|
||||
try {
|
||||
@@ -3679,9 +3907,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var mindmapRow = plan.mindmapRows[m];
|
||||
var mindmapId = fileTreeRowAssetId(mindmapRow);
|
||||
try {
|
||||
var mindmapDocId = fileTreeRowDocumentId(mindmapRow);
|
||||
var mindmapResponse = await fetch('/api/mindmap/' + encodeURIComponent(mindmapDocId) + '/' + encodeURIComponent(mindmapId), { method: 'DELETE' });
|
||||
if (!mindmapResponse.ok) throw new Error('mindmap_delete_failed_' + mindmapResponse.status);
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
await dispatchTreeCommand(trigger || mindmapRow, {
|
||||
action: 'archive',
|
||||
workspaceId: resolveWorkspaceId(mindmapRow),
|
||||
documentId: mindmapId
|
||||
});
|
||||
} else {
|
||||
var mindmapDocId = fileTreeRowDocumentId(mindmapRow);
|
||||
var mindmapResponse = await fetch('/api/mindmap/' + encodeURIComponent(mindmapDocId) + '/' + encodeURIComponent(mindmapId), { method: 'DELETE' });
|
||||
if (!mindmapResponse.ok) throw new Error('mindmap_delete_failed_' + mindmapResponse.status);
|
||||
}
|
||||
removeFileTreeAssetRow(mindmapId);
|
||||
} catch (error) {
|
||||
failures.push(mindmapId);
|
||||
@@ -3707,6 +3943,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return false;
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
|
||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7939,7 +8176,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
window.addEventListener('tree:title-updated', function(event) {
|
||||
var detail = event.detail || {};
|
||||
var previousDocumentId = detail.previousDocumentId || (detail.payload && detail.payload.result && detail.payload.result.previousDocumentId) || '';
|
||||
if (previousDocumentId && previousDocumentId !== detail.documentId) {
|
||||
removeDocumentRowForMode('page', previousDocumentId);
|
||||
removeDocumentRowForMode('filetree', previousDocumentId);
|
||||
if (currentDocumentId() === previousDocumentId) {
|
||||
navigateToDocument(detail.documentId, detail.workspaceId || currentWorkspaceId(), { treeView: activeSidebarTreeMode() });
|
||||
}
|
||||
}
|
||||
updateTitleEverywhere(detail.documentId, detail.title);
|
||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||
document.documentElement.setAttribute('data-mnote-title-local-applied', 'true');
|
||||
});
|
||||
|
||||
@@ -7947,12 +8193,24 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var detail = event.detail || {};
|
||||
var body = detail.body || {};
|
||||
var action = String(body.action || '').trim();
|
||||
var result = detail.result || {};
|
||||
if (action === 'create') {
|
||||
applyCreatedDocumentLocally(detail.result || {}, body.parentId || null, body.title || '新页面');
|
||||
applyCreatedDocumentLocally(result, body.parentId || null, body.title || '新页面');
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
return;
|
||||
}
|
||||
if (action === 'rename' && body.documentId && body.title) {
|
||||
updateTitleEverywhere(body.documentId, body.title);
|
||||
var newDocumentId = commandDocumentId(result, body.documentId);
|
||||
if (newDocumentId && newDocumentId !== body.documentId) {
|
||||
removeDocumentRowForMode('page', body.documentId);
|
||||
removeDocumentRowForMode('filetree', body.documentId);
|
||||
if (currentDocumentId() === body.documentId) {
|
||||
navigateToDocument(newDocumentId, body.workspaceId || currentWorkspaceId(), { treeView: activeSidebarTreeMode() });
|
||||
}
|
||||
} else {
|
||||
updateTitleEverywhere(body.documentId, body.title);
|
||||
}
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'rename');
|
||||
return;
|
||||
}
|
||||
@@ -7960,12 +8218,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move');
|
||||
}
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
return;
|
||||
}
|
||||
if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) {
|
||||
if (applyRemoveDocumentDelta({ documentId: body.documentId })) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove');
|
||||
}
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8215,6 +8475,11 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
|
||||
function start() {
|
||||
var bootstrap = readBootstrap();
|
||||
if (bootstrap.disabled === true || bootstrap.transport === 'disabled') {
|
||||
applyTransport('disabled');
|
||||
applyStatus('disabled');
|
||||
return;
|
||||
}
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sourceKind = (params.get('sourceKind') || '').trim();
|
||||
if (sourceKind === 'local_folder') {
|
||||
@@ -8291,6 +8556,9 @@ pub fn PageLayout(
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
/// 是否启用树实时流
|
||||
#[prop(optional, default = true)]
|
||||
enable_tree_live: bool,
|
||||
) -> impl IntoView {
|
||||
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
||||
|
||||
@@ -8321,7 +8589,8 @@ pub fn PageLayout(
|
||||
});
|
||||
let tree_live_bootstrap = serde_json::json!({
|
||||
"schema": "mnote.tree_live_bootstrap.v1",
|
||||
"transport": "convex-command-log-ws",
|
||||
"disabled": !enable_tree_live,
|
||||
"transport": if enable_tree_live { "convex-command-log-ws" } else { "disabled" },
|
||||
"workspaceId": null,
|
||||
"rootIds": [],
|
||||
"initialRevision": null,
|
||||
@@ -8452,6 +8721,7 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("openConvexAssetFromFileTree"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildMindmapOpenPath"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isMindmapAssetDetail"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("^思维导图"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("openMindmapAssetInDocumentShell"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-mindmap-asset-open-mode"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mindmap-object-shell"));
|
||||
@@ -8514,6 +8784,11 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sourceKind: currentSourceKind()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("rootUri: currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageContext: scopedContext.pageContext"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
||||
@@ -8605,6 +8880,8 @@ mod tests {
|
||||
fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("startLocalFolderSidebarWatch"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function currentWorkspaceId()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("var rootUri = currentRootUri();"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/tree/local-folder-watch"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/sidebar"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file"));
|
||||
@@ -8626,11 +8903,23 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isLocalUploadedAsset(asset)) return '';"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("localOfficeUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_runtime_routes_local_mindmap_and_office_assets() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("withLocalMindmapSourceParams"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('sourceKind', 'local_folder')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('rootUri', rootUri)"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isLocalAsset ? onlyOfficeUrl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_contains_dev_hot_reload_client() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload"));
|
||||
@@ -8687,7 +8976,7 @@ mod tests {
|
||||
.contains("renderFileRows('', groupRowsByParent(rows), activeId, activeRowId)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("renderFileRows(nodeId, grouped, activeId, activeRowId)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("normalizeMindmapFileTreeTitle"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("shortMindmapFileName(assetId || title)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("shortMindmapFileName(assetId)"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("var title = isFileTreeProjectionPageRow(rowKind, assetId)")
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user