chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes - preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs - refresh design governance docs, Reasonix task templates, and bug records - retire root .mcp.json local MCP config
This commit is contained in:
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, load_local_trash_entries,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
@@ -311,7 +311,7 @@ pub async fn root_entry(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
@@ -614,7 +614,7 @@ pub async fn trash_entry(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)
|
||||
ensure_local_workspace_read_access_with_state(&state, &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)?;
|
||||
@@ -1780,6 +1780,7 @@ fn handle_sqlite_auth_action(
|
||||
&resolved.user.id,
|
||||
resolved.user.email.as_deref().unwrap_or_default(),
|
||||
&resolved.user.display_name,
|
||||
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1809,18 +1810,20 @@ fn build_sqlite_auth_response(
|
||||
user_id: &str,
|
||||
email: &str,
|
||||
name: &str,
|
||||
actor_type: &str,
|
||||
) -> Response {
|
||||
let mut response = axum::Json(json!({
|
||||
"ok": true,
|
||||
"userId": user_id,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"authMode": "sqliteSession"
|
||||
"authMode": "sqliteSession",
|
||||
"actorType": actor_type
|
||||
}))
|
||||
.into_response();
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token);
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id);
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type);
|
||||
if !email.trim().is_empty() {
|
||||
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email);
|
||||
}
|
||||
@@ -1835,6 +1838,19 @@ fn build_sqlite_auth_response(
|
||||
response
|
||||
}
|
||||
|
||||
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
|
||||
let role = stored_role.trim();
|
||||
let fallback_role = if role.is_empty() { "user" } else { role };
|
||||
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
|
||||
user_id,
|
||||
fallback_role,
|
||||
) {
|
||||
"admin".to_string()
|
||||
} else {
|
||||
fallback_role.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
|
||||
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
||||
let token_hash = session_token_hash(&raw_token);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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,
|
||||
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
@@ -85,7 +85,7 @@ async fn project_projection(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let snapshot = if projection == KernelProjectionKind::FileTree {
|
||||
load_local_folder_file_tree_snapshot(root_uri)?
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_workspace_read_access,
|
||||
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
@@ -36,8 +36,9 @@ pub async fn local_folder_events(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderEventsQuery>,
|
||||
) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> {
|
||||
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let canonical_root =
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
|
||||
let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) {
|
||||
build_tree_live_stream(state, context, canonical_root, query.root_uri).await?
|
||||
|
||||
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -132,7 +132,7 @@ struct LocalShareGrant {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LocalAccessMode {
|
||||
pub(crate) enum LocalAccessMode {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
@@ -490,7 +490,7 @@ fn ensure_local_workspace_access_for_actor_with_mode(
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_local_workspace_access_with_state(
|
||||
pub(crate) fn ensure_local_workspace_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -525,18 +525,7 @@ fn ensure_local_workspace_access_with_state(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_read_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_read_access_for_actor(
|
||||
&context.auth.actor_id,
|
||||
&context.auth.actor_type,
|
||||
root_uri,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_local_workspace_read_access_with_state(
|
||||
pub(crate) fn ensure_local_workspace_read_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -544,6 +533,14 @@ fn ensure_local_workspace_read_access_with_state(
|
||||
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Read)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_write_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_path_read_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -587,6 +584,17 @@ pub(crate) fn ensure_local_path_read_access(
|
||||
Ok(canonical_target)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_read_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_read_access_for_actor(
|
||||
&context.auth.actor_id,
|
||||
&context.auth.actor_type,
|
||||
root_uri,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -598,14 +606,6 @@ pub(crate) fn ensure_local_workspace_access(
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_local_workspace_write_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write)
|
||||
}
|
||||
|
||||
fn local_access_policy_path() -> PathBuf {
|
||||
std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE)
|
||||
.ok()
|
||||
@@ -759,6 +759,10 @@ fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError>
|
||||
pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let actor_type = context.auth.actor_type.trim();
|
||||
is_local_access_policy_admin_actor(actor_id, actor_type)
|
||||
}
|
||||
|
||||
pub(crate) fn is_local_access_policy_admin_actor(actor_id: &str, actor_type: &str) -> bool {
|
||||
if actor_id.is_empty()
|
||||
|| actor_id == "anonymous"
|
||||
|| actor_type.is_empty()
|
||||
@@ -1629,6 +1633,7 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value {
|
||||
json!({
|
||||
"id": grant.id,
|
||||
"userId": grant.user_id,
|
||||
"workspaceId": grant.workspace_id,
|
||||
"rootUri": grant.root_uri,
|
||||
"rootPath": grant.root_path,
|
||||
"permission": grant.permission,
|
||||
@@ -1643,6 +1648,16 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
|
||||
grant.source.trim() == "auto"
|
||||
&& grant.permission.trim() == "write"
|
||||
&& grant.recursive
|
||||
&& grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str())
|
||||
&& grant.workspace_id.is_some()
|
||||
&& grant.root_uri.starts_with("local://users/")
|
||||
&& grant.root_uri.ends_with("/workspaces/my-space")
|
||||
}
|
||||
|
||||
fn sqlite_access_policy_payload(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -1689,7 +1704,10 @@ fn sqlite_user_access_policy_payload(
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
|
||||
let grant_values = grants
|
||||
.iter()
|
||||
.filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id))
|
||||
.filter(|grant| {
|
||||
grant.user_id.trim() == actor_id
|
||||
|| grant.created_by.as_deref().map(str::trim) == Some(actor_id)
|
||||
})
|
||||
.map(control_plane_grant_payload)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
@@ -1871,6 +1889,13 @@ fn delete_sqlite_user_access_grant_for_context(
|
||||
"目录授权不存在或不属于当前用户",
|
||||
)
|
||||
})?;
|
||||
if is_default_workspace_auto_grant(grant) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_system_grant_readonly",
|
||||
"默认空间的系统授权不能撤销",
|
||||
));
|
||||
}
|
||||
if grant.created_by.as_deref().map(str::trim) != Some(actor_id) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -1933,6 +1958,26 @@ fn delete_sqlite_local_access_grant_for_context(
|
||||
"必须提供 grantId",
|
||||
));
|
||||
}
|
||||
let grants = state
|
||||
.control_plane()
|
||||
.find_directory_grants(DirectoryGrantLookup {
|
||||
grant_id: Some(grant_id.to_string()),
|
||||
user_id: None,
|
||||
root_uri: None,
|
||||
include_revoked: false,
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
|
||||
if grants
|
||||
.first()
|
||||
.map(is_default_workspace_auto_grant)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_system_grant_readonly",
|
||||
"默认空间的系统授权不能撤销",
|
||||
));
|
||||
}
|
||||
state
|
||||
.control_plane()
|
||||
.revoke_directory_grant(grant_id, None)
|
||||
@@ -2710,7 +2755,16 @@ pub fn resolve_local_markdown_page_aggregate(
|
||||
})?;
|
||||
let parsed = parse_markdown_page(&markdown, &markdown_file.file_name);
|
||||
let title = parsed.title;
|
||||
let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body);
|
||||
let attachment_paths = uploaded_asset_markdown_relative_paths_for_document(
|
||||
&canonical_root,
|
||||
&metadata,
|
||||
document_id,
|
||||
&markdown_file.path,
|
||||
);
|
||||
let content = crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths(
|
||||
&parsed.body,
|
||||
&attachment_paths,
|
||||
);
|
||||
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
|
||||
let page_subtree = markdown_page_subtree(document_id, &title, &content);
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
@@ -3122,7 +3176,28 @@ pub async fn read_local_resource(
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
let content = if is_markdown_file(&file_name) {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
|
||||
let metadata = load_local_folder_metadata(&root)?;
|
||||
let target_relative_path = normalize_relative_path(&root, &target)?;
|
||||
let attachment_paths = metadata
|
||||
.uploaded_assets
|
||||
.get(&target_relative_path)
|
||||
.map(|entry| {
|
||||
uploaded_asset_markdown_relative_paths_for_document(
|
||||
&root,
|
||||
&metadata,
|
||||
&entry.document_id,
|
||||
&target,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if attachment_paths.is_empty() {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
|
||||
} else {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths(
|
||||
&text,
|
||||
&attachment_paths,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
text_to_editor_blocks(&text, &file_name)
|
||||
};
|
||||
@@ -6437,6 +6512,26 @@ fn find_markdown_by_page_id(
|
||||
walk(root, root, metadata, document_id)
|
||||
}
|
||||
|
||||
fn uploaded_asset_markdown_relative_paths_for_document(
|
||||
root: &Path,
|
||||
metadata: &LocalFolderMetadata,
|
||||
document_id: &str,
|
||||
markdown_path: &Path,
|
||||
) -> BTreeSet<String> {
|
||||
let Some(markdown_dir) = markdown_path.parent() else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
metadata
|
||||
.uploaded_assets
|
||||
.values()
|
||||
.filter(|entry| entry.document_id == document_id)
|
||||
.filter_map(|entry| {
|
||||
let path = root.join(&entry.relative_path);
|
||||
normalize_markdown_relative_asset_path(markdown_dir, &path).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
let mut item = json!({
|
||||
"rowId": row.row_id,
|
||||
@@ -7125,7 +7220,7 @@ fn editor_blocks_to_markdown_with_rewrite(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("paragraph");
|
||||
let inline_markdown = block_content_value(&block)
|
||||
.map(inline_nodes_to_markdown)
|
||||
.map(|content| inline_nodes_to_markdown(content, local_file_context))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
@@ -7319,7 +7414,11 @@ fn rewrite_local_open_url_to_markdown_relative(
|
||||
local_file_context: Option<(&Path, &Path)>,
|
||||
) -> Option<String> {
|
||||
let (root, markdown_path) = local_file_context?;
|
||||
let parsed = Url::parse(value).ok()?;
|
||||
let parsed = if value.starts_with('/') {
|
||||
Url::parse(&format!("http://localhost{}", value)).ok()?
|
||||
} else {
|
||||
Url::parse(value).ok()?
|
||||
};
|
||||
if parsed.path() != "/api/local-folder/files/open" {
|
||||
return None;
|
||||
}
|
||||
@@ -7605,7 +7704,7 @@ fn editor_block_table_to_markdown(block: &Value) -> String {
|
||||
.map(|cell| {
|
||||
let cell_text = cell
|
||||
.get("content")
|
||||
.map(inline_nodes_to_markdown)
|
||||
.map(|content| inline_nodes_to_markdown(content, None))
|
||||
.unwrap_or_default()
|
||||
.replace('\n', " ")
|
||||
.replace('|', r"\|")
|
||||
@@ -7715,29 +7814,33 @@ fn block_content_value(block: &Value) -> Option<&Value> {
|
||||
// 过渡实现:手写行内节点→Markdown 回写函数。从 editor block 的 content 数组中
|
||||
// 逐个节点提取 text + styles,按 legacy 样式格式输出为内联 Markdown。
|
||||
// AST + 中间 IR 迁移 complete 后应统一走 MarkdownInline→Markdown 反向映射。
|
||||
fn inline_nodes_to_markdown(value: &Value) -> String {
|
||||
fn inline_nodes_to_markdown(value: &Value, local_file_context: Option<(&Path, &Path)>) -> String {
|
||||
if let Some(text) = value.as_str() {
|
||||
return escape_markdown_inline_text(text);
|
||||
}
|
||||
if let Some(array) = value.as_array() {
|
||||
return array
|
||||
.iter()
|
||||
.map(inline_node_to_markdown)
|
||||
.map(|node| inline_node_to_markdown(node, local_file_context))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
}
|
||||
if let Some(object) = value.as_object() {
|
||||
if let Some(text) = object.get("text").and_then(Value::as_str) {
|
||||
return markdown_text_with_styles(text, &inline_styles_from_object(object));
|
||||
return markdown_text_with_styles(
|
||||
text,
|
||||
&inline_styles_from_object(object),
|
||||
local_file_context,
|
||||
);
|
||||
}
|
||||
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
|
||||
return inline_nodes_to_markdown(content);
|
||||
return inline_nodes_to_markdown(content, local_file_context);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn inline_node_to_markdown(node: &Value) -> String {
|
||||
fn inline_node_to_markdown(node: &Value, local_file_context: Option<(&Path, &Path)>) -> String {
|
||||
if let Some(text) = node.as_str() {
|
||||
return escape_markdown_inline_text(text);
|
||||
}
|
||||
@@ -7747,10 +7850,14 @@ fn inline_node_to_markdown(node: &Value) -> String {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
return markdown_text_with_styles(text, &inline_styles_from_object(object));
|
||||
return markdown_text_with_styles(
|
||||
text,
|
||||
&inline_styles_from_object(object),
|
||||
local_file_context,
|
||||
);
|
||||
}
|
||||
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
|
||||
return inline_nodes_to_markdown(content);
|
||||
return inline_nodes_to_markdown(content, local_file_context);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
@@ -7802,7 +7909,11 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
|
||||
Value::Object(styles)
|
||||
}
|
||||
|
||||
fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
|
||||
fn markdown_text_with_styles(
|
||||
text: &str,
|
||||
styles: &Value,
|
||||
local_file_context: Option<(&Path, &Path)>,
|
||||
) -> String {
|
||||
let mut value = escape_markdown_inline_text(text);
|
||||
let link = styles
|
||||
.get("link")
|
||||
@@ -7838,6 +7949,8 @@ fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
|
||||
value = format!("~~{value}~~");
|
||||
}
|
||||
if let Some(href) = link {
|
||||
let href =
|
||||
rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href);
|
||||
value = format!("[{}]({href})", value.replace(']', r"\]"));
|
||||
}
|
||||
value
|
||||
@@ -7995,14 +8108,14 @@ mod tests {
|
||||
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
|
||||
open_local_file, record_shared_cache, record_sync_pending_change,
|
||||
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
||||
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||
LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
|
||||
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
|
||||
SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
|
||||
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
};
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
@@ -8010,7 +8123,7 @@ mod tests {
|
||||
use axum::http::{HeaderMap, Method, StatusCode};
|
||||
use axum::Json;
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
@@ -8452,6 +8565,193 @@ fn main() {}
|
||||
assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_page_aggregate_preserves_uploaded_markdown_assets_as_media_blocks() {
|
||||
let root = temp_root("mnote-local-uploaded-md-assets-media");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
|
||||
let first = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes one\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload first md asset");
|
||||
let second = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes two\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload second md asset");
|
||||
assert_eq!(first["sourcePath"], "notes.md");
|
||||
assert_eq!(second["sourcePath"], "notes-1.md");
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
document_id,
|
||||
None,
|
||||
&json!([
|
||||
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Page"}]},
|
||||
{"type":"media","props":{"name":"notes.md","sourcePath":"notes.md"}},
|
||||
{"type":"media","props":{"name":"notes-1.md","sourcePath":"notes-1.md"}}
|
||||
]),
|
||||
)
|
||||
.expect("save uploaded md links");
|
||||
|
||||
let aggregate =
|
||||
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
|
||||
let media_paths = aggregate
|
||||
.body
|
||||
.content
|
||||
.as_array()
|
||||
.expect("blocks")
|
||||
.iter()
|
||||
.filter(|block| block["type"].as_str() == Some("media"))
|
||||
.map(|block| {
|
||||
block["props"]["sourcePath"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() {
|
||||
let root = temp_root("mnote-local-uploaded-md-inline-link");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
let asset = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload md asset");
|
||||
let href = format!(
|
||||
"http://127.0.0.1:3000/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md",
|
||||
root_uri
|
||||
);
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
document_id,
|
||||
None,
|
||||
&json!([
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "notes.md",
|
||||
"marks": [{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": href,
|
||||
"class": "mnote-uploaded-attachment"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]),
|
||||
)
|
||||
.expect("save inline attachment link");
|
||||
|
||||
assert_eq!(asset["sourcePath"], "notes.md");
|
||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||
assert!(saved.contains("[notes.md](notes.md)"));
|
||||
assert!(!saved.contains("/api/local-folder/files/open"));
|
||||
|
||||
let aggregate =
|
||||
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
|
||||
let media = aggregate
|
||||
.body
|
||||
.content
|
||||
.as_array()
|
||||
.expect("blocks")
|
||||
.iter()
|
||||
.find(|block| block["type"].as_str() == Some("media"))
|
||||
.expect("uploaded md inline link should reload as media block");
|
||||
assert_eq!(media["props"]["sourcePath"], "notes.md");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() {
|
||||
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload md asset");
|
||||
let href = format!(
|
||||
"/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md",
|
||||
root_uri
|
||||
);
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
document_id,
|
||||
None,
|
||||
&json!([
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "notes.md",
|
||||
"marks": [{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": href,
|
||||
"class": "mnote-uploaded-attachment-row"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]),
|
||||
)
|
||||
.expect("save relative open url");
|
||||
|
||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||
assert!(saved.contains("[notes.md](notes.md)"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() {
|
||||
let root = temp_root("mnote-local-markdown-save-basic-blocks");
|
||||
@@ -9382,6 +9682,16 @@ fn main() {}
|
||||
assert_eq!(listed["grants"][0]["id"], grant_id);
|
||||
assert_eq!(listed["grants"][0]["permission"], "write");
|
||||
|
||||
let (_, Json(target_listed)) = get_user_access_policy(
|
||||
State(state.clone()),
|
||||
Extension(request_context("user_target", "user")),
|
||||
)
|
||||
.await
|
||||
.expect("target can list incoming directory grants");
|
||||
assert_eq!(target_listed["controlPlane"], "sqlite");
|
||||
assert_eq!(target_listed["grants"][0]["id"], grant_id);
|
||||
assert_eq!(target_listed["grants"][0]["userId"], "user_target");
|
||||
|
||||
let target_delete_error = delete_user_access_grant(
|
||||
State(state.clone()),
|
||||
Extension(request_context("user_target", "user")),
|
||||
@@ -9426,6 +9736,44 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&outside_root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_access_policy_cannot_revoke_default_workspace_auto_grant() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("shujuan".into()),
|
||||
email: Some("shujuan@example.com".into()),
|
||||
username: "shujuan".into(),
|
||||
display_name: "shujuan".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.ensure_default_workspace("shujuan")
|
||||
.expect("default workspace");
|
||||
let grant_id = state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.expect("list grants")
|
||||
.into_iter()
|
||||
.find(|grant| grant.user_id == "shujuan" && grant.source == "auto")
|
||||
.map(|grant| grant.id)
|
||||
.expect("default auto grant");
|
||||
|
||||
let error = delete_user_access_grant(
|
||||
State(state.clone()),
|
||||
Extension(request_context("shujuan", "user")),
|
||||
AxumPath(grant_id),
|
||||
)
|
||||
.await
|
||||
.expect_err("default grant must be readonly");
|
||||
assert_eq!(error.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_share_grant_allows_any_local_folder_with_minimal_fields() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
@@ -10012,6 +10360,115 @@ fn main() {}
|
||||
assert!(markdown.contains("资源正文"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_resource_read_preserves_uploaded_markdown_attachment_links() {
|
||||
let root = temp_root("mnote-local-resource-read-uploaded-md-links");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
let resource = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "resource.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Resource\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload resource markdown");
|
||||
let first = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes one\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload first linked markdown");
|
||||
let second = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes two\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload second linked markdown");
|
||||
assert_eq!(resource["sourcePath"], "resource.md");
|
||||
assert_eq!(first["sourcePath"], "notes.md");
|
||||
assert_eq!(second["sourcePath"], "notes-1.md");
|
||||
std::fs::write(
|
||||
root.join("Page").join("resource.md"),
|
||||
"# Resource\n\n[notes.md](notes.md)\n\n[notes-1.md](notes-1.md)\n",
|
||||
)
|
||||
.expect("write resource links");
|
||||
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("resource_reader".into()),
|
||||
email: Some("resource-reader@example.com".into()),
|
||||
username: "resource_reader".into(),
|
||||
display_name: "resource_reader".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert resource reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "resource_reader".into(),
|
||||
workspace_id: None,
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.display()
|
||||
.to_string(),
|
||||
permission: "read".into(),
|
||||
recursive: true,
|
||||
capabilities: vec![],
|
||||
source: "test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant read");
|
||||
let context = request_context("resource_reader", "user");
|
||||
let (_, _, payload) = read_local_resource(
|
||||
State(state),
|
||||
Extension(context),
|
||||
Query(LocalResourceReadQuery {
|
||||
root_uri: root_uri.clone(),
|
||||
path: "Page/resource.md".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("read resource markdown");
|
||||
|
||||
let media_paths = payload["result"]["content"]
|
||||
.as_array()
|
||||
.expect("content blocks")
|
||||
.iter()
|
||||
.filter(|block| block["type"].as_str() == Some("media"))
|
||||
.map(|block| {
|
||||
block["props"]["sourcePath"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_delete_folder_moves_directory_to_trash() {
|
||||
let root = temp_root("mnote-local-delete-folder");
|
||||
@@ -10383,6 +10840,64 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root");
|
||||
let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config");
|
||||
let policy_file = policy_root.join("missing-access-policy.json");
|
||||
let state = test_state();
|
||||
let canonical_root = root.canonicalize().expect("canonical root");
|
||||
let canonical_root_path = canonical_root.display().to_string();
|
||||
let root_uri = format!("file://{canonical_root_path}");
|
||||
std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file");
|
||||
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("legacy_sqlite_reader".into()),
|
||||
email: Some("legacy-sqlite-reader@example.com".into()),
|
||||
username: "legacy_sqlite_reader".into(),
|
||||
display_name: "legacy_sqlite_reader".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "legacy_sqlite_reader".into(),
|
||||
workspace_id: None,
|
||||
root_uri: canonical_root_path.clone(),
|
||||
root_path: canonical_root_path,
|
||||
permission: "read".into(),
|
||||
recursive: true,
|
||||
capabilities: vec![],
|
||||
source: "legacy-test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant legacy sqlite read");
|
||||
|
||||
let context = request_context("legacy_sqlite_reader", "user");
|
||||
let (_, _, bytes) = open_local_file(
|
||||
State(state),
|
||||
Extension(context),
|
||||
Query(LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: "README.txt".into(),
|
||||
download: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("legacy sqlite read grant can open local file");
|
||||
assert_eq!(bytes, b"hello legacy sqlite");
|
||||
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
|
||||
|
||||
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
||||
use comrak::{parse_document, Arena, Options};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedLocalMarkdownPage {
|
||||
@@ -134,7 +135,14 @@ fn find_first_h1(body: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn markdown_to_blocks(markdown: &str) -> Value {
|
||||
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown))
|
||||
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
|
||||
}
|
||||
|
||||
pub fn markdown_to_blocks_with_attachment_paths(
|
||||
markdown: &str,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> Value {
|
||||
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
|
||||
}
|
||||
|
||||
fn markdown_options() -> Options<'static> {
|
||||
@@ -149,6 +157,13 @@ fn markdown_options() -> Options<'static> {
|
||||
}
|
||||
|
||||
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
|
||||
parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new())
|
||||
}
|
||||
|
||||
fn parse_markdown_attachment_link_with_paths(
|
||||
trimmed: &str,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> Option<(String, String)> {
|
||||
let value = trimmed
|
||||
.strip_prefix('!')
|
||||
.unwrap_or(trimmed)
|
||||
@@ -170,7 +185,9 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
|
||||
}
|
||||
let target_path = std::path::Path::new(target);
|
||||
let extension = target_path.extension().and_then(|value| value.to_str())?;
|
||||
if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") {
|
||||
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
|
||||
&& !attachment_paths.contains(target)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let fallback_name = target_path
|
||||
@@ -186,20 +203,27 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
|
||||
Some((name.to_string(), target.to_string()))
|
||||
}
|
||||
|
||||
fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument {
|
||||
fn parse_markdown_ast_document(
|
||||
markdown: &str,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> MarkdownAstDocument {
|
||||
let arena = Arena::new();
|
||||
let options = markdown_options();
|
||||
let root = parse_document(&arena, markdown, &options);
|
||||
let mut blocks = Vec::new();
|
||||
for node in root.children() {
|
||||
append_ast_block(node, &mut blocks);
|
||||
append_ast_block(node, &mut blocks, attachment_paths);
|
||||
}
|
||||
MarkdownAstDocument { blocks }
|
||||
}
|
||||
|
||||
fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
|
||||
fn append_ast_block<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
blocks: &mut Vec<MarkdownBlock>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) {
|
||||
match node.data.borrow().value.clone() {
|
||||
NodeValue::Paragraph => append_ast_paragraph(node, blocks),
|
||||
NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths),
|
||||
NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading {
|
||||
level: heading.level,
|
||||
content: collect_inline_children(node),
|
||||
@@ -212,7 +236,12 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
|
||||
}),
|
||||
NodeValue::List(list) => {
|
||||
for item in node.children() {
|
||||
append_ast_list_item(item, list.list_type == ListType::Ordered, blocks);
|
||||
append_ast_list_item(
|
||||
item,
|
||||
list.list_type == ListType::Ordered,
|
||||
blocks,
|
||||
attachment_paths,
|
||||
);
|
||||
}
|
||||
}
|
||||
NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)),
|
||||
@@ -233,7 +262,11 @@ 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>) {
|
||||
fn append_ast_paragraph<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
blocks: &mut Vec<MarkdownBlock>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) {
|
||||
if let Some((alt, source_path)) = paragraph_image(node) {
|
||||
blocks.push(MarkdownBlock::Image { alt, source_path });
|
||||
return;
|
||||
@@ -242,11 +275,13 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
|
||||
blocks.push(MarkdownBlock::Mindmap { name, source_path });
|
||||
return;
|
||||
}
|
||||
if let Some((name, source_path)) = paragraph_attachment_media(node) {
|
||||
if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) {
|
||||
blocks.push(MarkdownBlock::Media { name, source_path });
|
||||
return;
|
||||
}
|
||||
if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) {
|
||||
if let Some((name, source_path, remaining)) =
|
||||
paragraph_leading_attachment_media(node, attachment_paths)
|
||||
{
|
||||
blocks.push(MarkdownBlock::Media { name, source_path });
|
||||
let content = merge_adjacent_inline_nodes(remaining);
|
||||
if !content.is_empty() {
|
||||
@@ -257,17 +292,22 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
|
||||
blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node)));
|
||||
}
|
||||
|
||||
fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec<MarkdownBlock>) {
|
||||
fn append_ast_list_item<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
ordered: bool,
|
||||
blocks: &mut Vec<MarkdownBlock>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) {
|
||||
let (is_task, checked) = match node.data.borrow().value.clone() {
|
||||
NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()),
|
||||
NodeValue::Item(_) => (false, false),
|
||||
_ => return append_ast_block(node, blocks),
|
||||
_ => return append_ast_block(node, blocks, attachment_paths),
|
||||
};
|
||||
let mut content = Vec::new();
|
||||
for child in node.children() {
|
||||
match child.data.borrow().value.clone() {
|
||||
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
|
||||
_ => append_ast_block(child, blocks),
|
||||
_ => append_ast_block(child, blocks, attachment_paths),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,13 +444,16 @@ fn merge_adjacent_inline_nodes(nodes: Vec<MarkdownInline>) -> Vec<MarkdownInline
|
||||
merged
|
||||
}
|
||||
|
||||
fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
fn paragraph_attachment_media<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> Option<(String, String)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
if children.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
link_attachment_media(first)
|
||||
link_attachment_media(first, attachment_paths)
|
||||
}
|
||||
|
||||
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
@@ -437,10 +480,11 @@ fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
|
||||
fn paragraph_leading_attachment_media<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> Option<(String, String, Vec<MarkdownInline>)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
let (name, source_path) = link_attachment_media(first)?;
|
||||
let (name, source_path) = link_attachment_media(first, attachment_paths)?;
|
||||
let second = children.next()?;
|
||||
if !matches!(
|
||||
second.data.borrow().value,
|
||||
@@ -455,11 +499,17 @@ fn paragraph_leading_attachment_media<'a>(
|
||||
Some((name, source_path, remaining))
|
||||
}
|
||||
|
||||
fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
fn link_attachment_media<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> Option<(String, String)> {
|
||||
let NodeValue::Link(link) = &node.data.borrow().value else {
|
||||
return None;
|
||||
};
|
||||
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
|
||||
parse_markdown_attachment_link_with_paths(
|
||||
&format!("[{}]({})", collect_plain_text(node), link.url),
|
||||
attachment_paths,
|
||||
)
|
||||
}
|
||||
|
||||
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
|
||||
@@ -3,8 +3,8 @@ 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,
|
||||
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||
read_local_mindmap_data, write_local_mindmap_data,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
@@ -151,7 +151,7 @@ pub async fn get_mindmap(
|
||||
}
|
||||
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)
|
||||
ensure_local_workspace_read_access_with_state(&state, &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))?;
|
||||
@@ -228,7 +228,7 @@ pub async fn apply_mindmap_command(
|
||||
.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)
|
||||
ensure_local_workspace_write_access_with_state(&state, &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)
|
||||
|
||||
@@ -1268,7 +1268,9 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write md");
|
||||
let markdown_path = root.join("Page").join("Page.md");
|
||||
let original_markdown = "# Page\n\n[map](map.mindmap.json)\n";
|
||||
std::fs::write(&markdown_path, original_markdown).expect("write md");
|
||||
std::fs::write(
|
||||
root.join("Page").join("map.mindmap.json"),
|
||||
r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#,
|
||||
@@ -1313,6 +1315,11 @@ mod tests {
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
|
||||
original_markdown,
|
||||
"mindmap archive 只移动资源文件,不应改写正文引用"
|
||||
);
|
||||
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
|
||||
.expect("trash index");
|
||||
assert!(trash_index.contains("local-file:Page/map.mindmap.json"));
|
||||
@@ -1354,6 +1361,11 @@ mod tests {
|
||||
"tree.resource.restore"
|
||||
);
|
||||
assert!(root.join("Page").join("map.mindmap.json").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&markdown_path).expect("read markdown after restore"),
|
||||
original_markdown,
|
||||
"mindmap restore 只恢复资源文件,不应改写正文引用"
|
||||
);
|
||||
|
||||
let delete_again = app()
|
||||
.oneshot(
|
||||
|
||||
@@ -179,8 +179,10 @@ pub async fn documents(
|
||||
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state, &context, root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
local_search_index::query_local_search_index(
|
||||
&root_path,
|
||||
root_uri,
|
||||
@@ -227,6 +229,7 @@ pub async fn documents(
|
||||
}
|
||||
|
||||
pub async fn refresh_local_index(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<LocalSearchIndexRefreshRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
@@ -241,8 +244,10 @@ pub async fn refresh_local_index(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state, &context, root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let refreshed = local_search_index::refresh_local_search_index(
|
||||
&root_path,
|
||||
root_uri,
|
||||
@@ -268,6 +273,7 @@ pub async fn refresh_local_index(
|
||||
}
|
||||
|
||||
pub async fn local_index_backlinks(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalSearchIndexQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
@@ -294,8 +300,10 @@ pub async fn local_index_backlinks(
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state, &context, root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let backlinks = local_search_index::query_local_backlinks(
|
||||
&root_path,
|
||||
root_uri,
|
||||
@@ -322,6 +330,7 @@ pub async fn local_index_backlinks(
|
||||
}
|
||||
|
||||
pub async fn local_index_tags(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalSearchIndexQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
@@ -336,8 +345,10 @@ pub async fn local_index_tags(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state, &context, root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
|
||||
@@ -78,10 +78,10 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
|
||||
return SessionResponse {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
user_id: resolved.user.id,
|
||||
user_id: resolved.user.id.clone(),
|
||||
email: resolved.user.email.unwrap_or_default(),
|
||||
name: resolved.user.display_name,
|
||||
actor_type: "user".to_string(),
|
||||
actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role),
|
||||
auth_mode: "sqliteSession",
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
@@ -109,7 +109,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
|
||||
state.config().dev_user_id.clone()
|
||||
};
|
||||
let actor_type = if has_forwarded_actor {
|
||||
context.auth.actor_type
|
||||
effective_actor_type_for_user(actor_id, &context.auth.actor_type)
|
||||
} else {
|
||||
"devFallback".to_string()
|
||||
};
|
||||
@@ -139,6 +139,19 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
|
||||
let role = stored_role.trim();
|
||||
let fallback_role = if role.is_empty() { "user" } else { role };
|
||||
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
|
||||
user_id,
|
||||
fallback_role,
|
||||
) {
|
||||
"admin".to_string()
|
||||
} else {
|
||||
fallback_role.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
|
||||
let token = context
|
||||
.auth
|
||||
@@ -376,6 +389,155 @@ mod tests {
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_returns_admin_for_sqlite_admin_user() {
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("liaibo".into()),
|
||||
email: Some("liaibo@yeah.net".into()),
|
||||
username: "liaibo".into(),
|
||||
display_name: "liaibo".into(),
|
||||
role: Some("admin".into()),
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert admin user");
|
||||
state
|
||||
.control_plane()
|
||||
.create_session(CreateSessionInput {
|
||||
id: None,
|
||||
user_id: "liaibo".into(),
|
||||
token_hash: session_token_hash("raw-admin-session-token"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("create admin session");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("cookie", "mnote_session=raw-admin-session-token")
|
||||
.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: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["userId"], "liaibo");
|
||||
assert_eq!(payload["actorType"], "admin");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_returns_admin_for_local_access_policy_admin() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let policy_root = std::env::temp_dir().join(format!(
|
||||
"mnote-session-access-policy-admin-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
std::fs::create_dir_all(&policy_root).expect("create policy root");
|
||||
let policy_file = policy_root.join("access-policy.json");
|
||||
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
|
||||
.expect("write access policy");
|
||||
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
||||
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("liaibo".into()),
|
||||
email: Some("liaibo@yeah.net".into()),
|
||||
username: "liaibo".into(),
|
||||
display_name: "liaibo".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert policy admin user");
|
||||
state
|
||||
.control_plane()
|
||||
.create_session(CreateSessionInput {
|
||||
id: None,
|
||||
user_id: "liaibo".into(),
|
||||
token_hash: session_token_hash("raw-policy-admin-session-token"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("create policy admin session");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("cookie", "mnote_session=raw-policy-admin-session-token")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["userId"], "liaibo");
|
||||
assert_eq!(payload["actorType"], "admin");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_whoami_alias_prefers_forwarded_actor_identity() {
|
||||
let response = app()
|
||||
|
||||
@@ -6,10 +6,10 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, ensure_local_workspace_read_access,
|
||||
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
|
||||
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_workspace_id_from_root_uri,
|
||||
local_workspace_id_from_root_uri, LocalAccessMode,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
@@ -6084,10 +6084,11 @@ fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<V
|
||||
}
|
||||
|
||||
pub async fn local_folder_watch(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderWatchQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision = local_folder_watch_revision(&query.root_uri)?;
|
||||
Ok(json_response(
|
||||
@@ -6185,7 +6186,7 @@ pub async fn tree_shell(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&effective_context, root_uri)
|
||||
ensure_local_workspace_read_access_with_state(&state, &effective_context, root_uri)
|
||||
.map_err(|error| error.with_context(&effective_context))?;
|
||||
(
|
||||
local_workspace_id_from_root_uri(root_uri)?,
|
||||
@@ -6808,8 +6809,13 @@ pub async fn tree_command(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
ensure_local_workspace_access_with_state(
|
||||
&state,
|
||||
&context,
|
||||
root_uri,
|
||||
LocalAccessMode::Write,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let execution = execute_local_tree_command_with_sort(
|
||||
root_uri,
|
||||
action,
|
||||
@@ -6932,6 +6938,7 @@ mod tests {
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -7223,6 +7230,88 @@ mod tests {
|
||||
assert!(html.contains("data-document-id=\"local-md:README.md\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_shell_local_folder_allows_sqlite_directory_read_grant() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-folder-sqlite-read-grant-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::write(root.join("README.md"), "# Shared\n").expect("write local md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "owner_user");
|
||||
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(user_id.into()),
|
||||
email: Some(format!("{user_id}@example.com")),
|
||||
username: user_id.into(),
|
||||
display_name: user_id.into(),
|
||||
role: role.map(str::to_string),
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert grant user");
|
||||
}
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "shujuan".into(),
|
||||
workspace_id: None,
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.display()
|
||||
.to_string(),
|
||||
permission: "read".into(),
|
||||
recursive: true,
|
||||
capabilities: vec![],
|
||||
source: "admin".into(),
|
||||
created_by: Some("liaibo".into()),
|
||||
})
|
||||
.expect("grant sqlite read");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "shujuan")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_tree_shell_does_not_reload_page_for_refresh() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
@@ -7778,6 +7867,89 @@ mod tests {
|
||||
assert_eq!(payload["code"], "local_workspace_access_denied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_allows_sqlite_directory_write_grant() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-tree-sqlite-write-grant-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
init_local_workspace(&root, "owner_user");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(user_id.into()),
|
||||
email: Some(format!("{user_id}@example.com")),
|
||||
username: user_id.into(),
|
||||
display_name: user_id.into(),
|
||||
role: role.map(str::to_string),
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert grant user");
|
||||
}
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "shujuan".into(),
|
||||
workspace_id: None,
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.display()
|
||||
.to_string(),
|
||||
permission: "write".into(),
|
||||
recursive: true,
|
||||
capabilities: vec![],
|
||||
source: "admin".into(),
|
||||
created_by: Some("liaibo".into()),
|
||||
})
|
||||
.expect("grant sqlite write");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "shujuan")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"授权新页面"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_shell_embeds_renderer_input_contract() {
|
||||
let filetree_response = app()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,7 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]');
|
||||
var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]');
|
||||
var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]');
|
||||
var currentActorId = document.body && document.body.getAttribute ? String(document.body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
||||
var pageConfig = (function () {
|
||||
var node = root.querySelector('#__MNOTE_ACCESS_POLICY_PAGE__');
|
||||
try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; }
|
||||
@@ -147,6 +148,48 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
return suffix ? base + suffix : base;
|
||||
}
|
||||
|
||||
function pathToFileRootUri(value) {
|
||||
var trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^file:\/\//i.test(trimmed)) return trimmed;
|
||||
if (trimmed.charAt(0) !== '/') return '';
|
||||
return 'file://' + trimmed.split('/').map(function(part, index) {
|
||||
return index === 0 ? '' : encodeURIComponent(part);
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function localFolderOpenHref(grant) {
|
||||
var rootUri = String(grant.rootUri || '').trim();
|
||||
if (rootUri && !/^file:\/\//i.test(rootUri)) rootUri = '';
|
||||
if (!rootUri) {
|
||||
var rootPath = String(grant.rootPath || '').trim();
|
||||
if (rootPath) rootUri = pathToFileRootUri(rootPath);
|
||||
}
|
||||
if (!rootUri) {
|
||||
rootUri = pathToFileRootUri(String(grant.rootUri || '').trim());
|
||||
}
|
||||
if (!rootUri) return '';
|
||||
var url = new URL('/', window.location.origin);
|
||||
url.searchParams.set('treeView', 'filetree');
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
function isDefaultWorkspaceAutoGrant(grant) {
|
||||
var source = String(grant && grant.source || '').trim();
|
||||
var workspaceId = String(grant && grant.workspaceId || '').trim();
|
||||
var rootUri = String(grant && grant.rootUri || '').trim();
|
||||
var permission = String(grant && grant.permission || '').trim();
|
||||
var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim();
|
||||
var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim();
|
||||
return source === 'auto'
|
||||
&& workspaceId
|
||||
&& permission === 'write'
|
||||
&& createdBy === targetUser
|
||||
&& /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri);
|
||||
}
|
||||
|
||||
function renderShareGrants(payload) {
|
||||
if (!shareGrantsList) return;
|
||||
var grants = readDirectoryGrants(payload);
|
||||
@@ -156,12 +199,20 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
}
|
||||
shareGrantsList.innerHTML = grants.map(function(grant) {
|
||||
var active = grant.active === false || grant.status === 'revoked' ? '已撤销' : '有效';
|
||||
var revokeButton = grant.active === false ? '' :
|
||||
var openHref = localFolderOpenHref(grant);
|
||||
var rootLabel = escapeHtml(grant.rootPath || grant.rootUri || '');
|
||||
var createdBy = String(grant.createdBy || grant.ownerUserId || '').trim();
|
||||
var systemOwned = isDefaultWorkspaceAutoGrant(grant);
|
||||
var canRevoke = !systemOwned && (isAdmin || (createdBy && currentActorId && createdBy === currentActorId));
|
||||
var rootNode = openHref
|
||||
? '<a class="mnote-admin-policy-root-link" data-testid="mnote-admin-open-granted-root" href="' + escapeHtml(openHref) + '">' + rootLabel + '</a>'
|
||||
: '<strong>' + rootLabel + '</strong>';
|
||||
var revokeButton = grant.active === false || !canRevoke ? '' :
|
||||
'<button type="button" data-admin-action="revoke-access-grant" data-grant-id="' + escapeHtml(grant.id || '') + '">撤销</button>';
|
||||
return '<article class="mnote-admin-policy-row">' +
|
||||
'<div><strong>' + escapeHtml(grant.rootPath || grant.rootUri || '') + '</strong><span>授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' +
|
||||
'<div>' + rootNode + '<span>授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' +
|
||||
'<div>' + renderBadge(grant.permission) + renderBadge(active) + '</div>' +
|
||||
'<div><span>创建者 ' + escapeHtml(grant.createdBy || grant.ownerUserId || '') + '</span><span>授权 ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' +
|
||||
'<div><span>创建者 ' + escapeHtml(createdBy) + '</span><span>授权 ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' +
|
||||
'</article>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -203,7 +254,8 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
|
||||
root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = shareGrantFormValues(event.currentTarget);
|
||||
var form = event.currentTarget;
|
||||
var values = shareGrantFormValues(form);
|
||||
setText(createShareGrantResult, '正在创建...');
|
||||
requestJson(accessPolicyUrl('/grants'), {
|
||||
method: 'POST',
|
||||
@@ -211,7 +263,7 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
}).then(function (payload) {
|
||||
setText(createShareGrantResult, payload);
|
||||
setText(shareGrantsMessage, '文件夹授权已创建');
|
||||
event.currentTarget.reset();
|
||||
form.reset();
|
||||
return refreshShareGrants();
|
||||
}).catch(function (error) {
|
||||
setText(createShareGrantResult, { ok: false, error: error.message || '创建失败' });
|
||||
@@ -292,4 +344,20 @@ mod tests {
|
||||
assert!(!html.contains("capabilities"));
|
||||
assert!(!html.contains("mnote-admin-validate-root-submit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_policy_script_keeps_form_reference_for_async_reset() {
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("var form = event.currentTarget;"));
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("form.reset();"));
|
||||
assert!(!ADMIN_POLICY_SCRIPT.contains("event.currentTarget.reset();"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_policy_script_links_grants_to_local_folder_entry() {
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("function localFolderOpenHref(grant)"));
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("sourceKind', 'local_folder'"));
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("data-testid=\"mnote-admin-open-granted-root\""));
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("function isDefaultWorkspaceAutoGrant(grant)"));
|
||||
assert!(ADMIN_POLICY_SCRIPT.contains("var canRevoke = !systemOwned &&"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,17 +94,6 @@ fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
|
||||
rows="1"
|
||||
>{model.title.clone()}</textarea>
|
||||
</h1>
|
||||
<Show when={move || model.pane_role == "secondary"}>
|
||||
<button
|
||||
type="button"
|
||||
class="document-pane-close"
|
||||
data-mnote-pane-close="secondary"
|
||||
aria-label="关闭右侧文档"
|
||||
title="关闭右侧文档"
|
||||
>
|
||||
<span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="document-shell-meta" aria-label="页面元信息">
|
||||
<span data-page-title-current="true">{model.title.clone()}</span>
|
||||
@@ -297,13 +286,14 @@ pub fn DocumentPage(
|
||||
data-testid="mnote-document-workspace"
|
||||
data-has-secondary-pane={secondary_visible.to_string()}
|
||||
>
|
||||
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host">
|
||||
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" role="tablist" aria-label="主编辑区标签页">
|
||||
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host" data-pane-role="primary">
|
||||
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="primary" role="tablist" aria-label="主编辑区标签页">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-main-tab is-active"
|
||||
data-mnote-main-tab="page"
|
||||
data-mnote-tab-kind="page"
|
||||
data-pane-role="primary"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
@@ -313,16 +303,17 @@ pub fn DocumentPage(
|
||||
</button>
|
||||
</div>
|
||||
<div class="mnote-main-tab-panels">
|
||||
<div data-mnote-page-tab-panel="true">
|
||||
<div data-mnote-page-tab-panel="true" data-pane-role="primary">
|
||||
<DocumentPane model={primary_model} />
|
||||
</div>
|
||||
<section
|
||||
class="mnote-resource-tab-host"
|
||||
data-mnote-resource-tab-host="true"
|
||||
data-pane-role="primary"
|
||||
data-testid="mnote-resource-tab-host"
|
||||
hidden=true
|
||||
>
|
||||
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true"></div>
|
||||
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="primary"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -333,7 +324,38 @@ pub fn DocumentPage(
|
||||
aria-hidden="true"
|
||||
hidden={!secondary_visible}
|
||||
></div>
|
||||
<DocumentPane model={secondary_model} />
|
||||
<section class="document-main-editor-group" data-testid="mnote-secondary-editor-tab-host" data-pane-role="secondary" hidden={!secondary_visible}>
|
||||
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="secondary" role="tablist" aria-label="右侧编辑区标签页">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-main-tab is-active"
|
||||
data-mnote-main-tab="page"
|
||||
data-mnote-tab-kind="page"
|
||||
data-pane-role="secondary"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
|
||||
<span class="mnote-main-tab-title">{secondary_model.title.clone()}</span>
|
||||
<span class="mnote-main-tab-close" role="button" data-mnote-pane-close="secondary" aria-label="关闭右侧文档" title="关闭右侧文档">{"×"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mnote-main-tab-panels">
|
||||
<div data-mnote-page-tab-panel="true" data-pane-role="secondary">
|
||||
<DocumentPane model={secondary_model} />
|
||||
</div>
|
||||
<section
|
||||
class="mnote-resource-tab-host"
|
||||
data-mnote-resource-tab-host="true"
|
||||
data-pane-role="secondary"
|
||||
data-testid="mnote-secondary-resource-tab-host"
|
||||
hidden=true
|
||||
>
|
||||
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="secondary"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="sr-only" data-mnote-workspace-label>{workspace_label}</div>
|
||||
</PageLayout>
|
||||
|
||||
@@ -97,6 +97,57 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function installWorkspaceSidebarResizer() {
|
||||
var shell = document.querySelector('.mnote-shell, .wolai-workspace-shell');
|
||||
var sidebar = document.querySelector('[data-testid="wolai-sidebar"]');
|
||||
var resizer = document.querySelector('[data-mnote-sidebar-resizer="true"]');
|
||||
if (!(shell instanceof HTMLElement) || !(sidebar instanceof HTMLElement) || !(resizer instanceof HTMLElement)) return;
|
||||
if (resizer.getAttribute('data-mnote-sidebar-resizer-bound') === 'true') return;
|
||||
resizer.setAttribute('data-mnote-sidebar-resizer-bound', 'true');
|
||||
var storageKey = 'mnote.workspace.sidebarWidth.v1';
|
||||
var minWidth = 220;
|
||||
var maxWidth = 520;
|
||||
function clampWidth(value) {
|
||||
var width = Number(value);
|
||||
if (!Number.isFinite(width)) return 248;
|
||||
return Math.max(minWidth, Math.min(maxWidth, width));
|
||||
}
|
||||
function applyWidth(value) {
|
||||
var width = clampWidth(value);
|
||||
shell.style.setProperty('--mnote-sidebar-width', width + 'px');
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-width', String(width));
|
||||
return width;
|
||||
}
|
||||
try {
|
||||
var stored = Number(window.localStorage.getItem(storageKey) || '');
|
||||
if (stored) applyWidth(stored);
|
||||
} catch (_) {}
|
||||
resizer.addEventListener('pointerdown', function(event) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
var startX = event.clientX;
|
||||
var startWidth = sidebar.getBoundingClientRect().width || clampWidth(0);
|
||||
resizer.setPointerCapture(event.pointerId);
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-resizing', 'true');
|
||||
function onMove(moveEvent) {
|
||||
applyWidth(startWidth + moveEvent.clientX - startX);
|
||||
}
|
||||
function onEnd(endEvent) {
|
||||
resizer.removeEventListener('pointermove', onMove);
|
||||
resizer.removeEventListener('pointerup', onEnd);
|
||||
resizer.removeEventListener('pointercancel', onEnd);
|
||||
try { resizer.releasePointerCapture(endEvent.pointerId); } catch (_) {}
|
||||
document.documentElement.removeAttribute('data-mnote-sidebar-resizing');
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(Math.round(sidebar.getBoundingClientRect().width)));
|
||||
} catch (_) {}
|
||||
}
|
||||
resizer.addEventListener('pointermove', onMove);
|
||||
resizer.addEventListener('pointerup', onEnd);
|
||||
resizer.addEventListener('pointercancel', onEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
@@ -124,9 +175,62 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function currentDocumentId() {
|
||||
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
|
||||
if (!match) match = window.location.pathname.match(/^\/mindmap\/([^\/]+)\/([^\/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromQuery = (params.get('documentId') || params.get('pageId') || '').trim();
|
||||
if (fromQuery) return fromQuery;
|
||||
var activePane = document.querySelector('.document-pane[data-pane-role="primary"][data-pane-document-id], .document-pane[data-pane-visible="true"][data-pane-document-id]');
|
||||
if (activePane instanceof HTMLElement) {
|
||||
var paneDocumentId = (activePane.getAttribute('data-pane-document-id') || '').trim();
|
||||
if (paneDocumentId) return paneDocumentId;
|
||||
}
|
||||
var shell = document.querySelector('.document-shell[data-document-id]');
|
||||
if (shell instanceof HTMLElement) return (shell.getAttribute('data-document-id') || '').trim();
|
||||
return '';
|
||||
}
|
||||
|
||||
function localFolderSelfChangeSuppressions() {
|
||||
window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map();
|
||||
return window.__mnoteLocalFolderSelfChangeSuppressions;
|
||||
}
|
||||
|
||||
function persistLocalFolderSelfChangeSuppression(documentId, expiresAt) {
|
||||
var doc = String(documentId || '').trim();
|
||||
if (!doc) return;
|
||||
localFolderSelfChangeSuppressions().set(doc, expiresAt);
|
||||
try {
|
||||
var key = 'mnote.localFolder.selfChangeSuppressions.v1';
|
||||
var existing = JSON.parse(window.sessionStorage.getItem(key) || '{}');
|
||||
existing[doc] = expiresAt;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(existing));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
var EDITOR_UPLOAD_ROOT_SELECTOR = '[data-editor-host-kind="leptos_tiptap_island"], [data-editor-host-kind="leptos_tiptap_resource"]';
|
||||
|
||||
function editorUploadRootFromElement(target) {
|
||||
if (!(target instanceof Element)) return null;
|
||||
var root = target.closest(EDITOR_UPLOAD_ROOT_SELECTOR);
|
||||
return root instanceof HTMLElement ? root : null;
|
||||
}
|
||||
|
||||
function rememberEditorUploadRootFromTarget(target) {
|
||||
var root = editorUploadRootFromElement(target);
|
||||
if (root instanceof HTMLElement) {
|
||||
window.__mnoteLastEditorUploadRoot = root;
|
||||
return root;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', function(event) {
|
||||
rememberEditorUploadRootFromTarget(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('focusin', function(event) {
|
||||
rememberEditorUploadRootFromTarget(event.target);
|
||||
}, true);
|
||||
|
||||
function currentFileTreeActiveRowId() {
|
||||
var explicitRowId = new URL(window.location.href).searchParams.get('restoreFocusRowId') || '';
|
||||
if (explicitRowId) return explicitRowId;
|
||||
@@ -779,6 +883,34 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function recentLocalRootLabel(rootUri) {
|
||||
var label = fileRootUriToPathInput(rootUri);
|
||||
return label || '本地文件夹';
|
||||
}
|
||||
|
||||
function normalizeGrantedLocalFolderRootUri(grant) {
|
||||
var rootUri = String(grant && grant.rootUri || '').trim();
|
||||
if (rootUri && /^file:\/\//i.test(rootUri)) return rootUri;
|
||||
var rootPath = String(grant && grant.rootPath || '').trim();
|
||||
if (rootPath) return pathToFileRootUri(rootPath);
|
||||
if (rootUri) return pathToFileRootUri(rootUri);
|
||||
return '';
|
||||
}
|
||||
|
||||
function isDefaultWorkspaceAutoGrant(grant) {
|
||||
var source = String(grant && grant.source || '').trim();
|
||||
var workspaceId = String(grant && grant.workspaceId || '').trim();
|
||||
var rootUri = String(grant && grant.rootUri || '').trim();
|
||||
var permission = String(grant && grant.permission || '').trim();
|
||||
var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim();
|
||||
var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim();
|
||||
return source === 'auto'
|
||||
&& workspaceId
|
||||
&& permission === 'write'
|
||||
&& createdBy === targetUser
|
||||
&& /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri);
|
||||
}
|
||||
|
||||
function openLocalFolderRoot(rootUri) {
|
||||
if (currentSourceKind() !== 'local_folder') {
|
||||
rememberCurrentCloudWorkspaceId();
|
||||
@@ -1066,6 +1198,49 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
card.appendChild(title);
|
||||
card.appendChild(status);
|
||||
card.appendChild(input);
|
||||
var authorizedSection = document.createElement('div');
|
||||
authorizedSection.className = 'mnote-local-folder-dialog__authorized';
|
||||
authorizedSection.setAttribute('data-testid', 'mnote-local-folder-authorized-roots');
|
||||
authorizedSection.hidden = true;
|
||||
card.appendChild(authorizedSection);
|
||||
fetch('/api/user/access-policy', {
|
||||
method: 'GET',
|
||||
headers: { 'accept': 'application/json' },
|
||||
credentials: 'include'
|
||||
}).then(function(response) {
|
||||
return response.ok ? response.json() : null;
|
||||
}).then(function(payload) {
|
||||
var grants = payload && Array.isArray(payload.grants) ? payload.grants : [];
|
||||
var activeGrants = grants.filter(function(grant) {
|
||||
return grant && grant.active !== false && grant.status !== 'revoked';
|
||||
});
|
||||
if (!activeGrants.length) return;
|
||||
authorizedSection.hidden = false;
|
||||
var authorizedTitle = document.createElement('div');
|
||||
authorizedTitle.style.fontSize = '12px';
|
||||
authorizedTitle.style.color = '#6b7280';
|
||||
authorizedTitle.textContent = '已授权文件夹';
|
||||
var authorizedList = document.createElement('div');
|
||||
authorizedList.className = 'mnote-local-folder-dialog__recent';
|
||||
activeGrants.slice(0, 8).forEach(function(grant) {
|
||||
if (isDefaultWorkspaceAutoGrant(grant)) return;
|
||||
var rootUri = normalizeGrantedLocalFolderRootUri(grant);
|
||||
if (!rootUri) return;
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.setAttribute('data-testid', 'mnote-local-folder-authorized-root');
|
||||
button.textContent = fileRootUriToPathInput(rootUri);
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeLocalFolderDialog();
|
||||
openLocalFolderRoot(rootUri);
|
||||
});
|
||||
authorizedList.appendChild(button);
|
||||
});
|
||||
if (!authorizedList.childElementCount) return;
|
||||
authorizedSection.appendChild(authorizedTitle);
|
||||
authorizedSection.appendChild(authorizedList);
|
||||
}).catch(function() {});
|
||||
if (recent.length) {
|
||||
var recentTitle = document.createElement('div');
|
||||
recentTitle.style.fontSize = '12px';
|
||||
@@ -1077,7 +1252,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
recent.slice(0, 5).forEach(function(rootUri) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = rootUri.replace(/^file:\/\//, '');
|
||||
button.textContent = recentLocalRootLabel(rootUri);
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeLocalFolderDialog();
|
||||
@@ -1569,8 +1744,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var nextWorkspaceId = result.workspaceId || workspaceId;
|
||||
var nextDocumentId = commandDocumentId(result, '');
|
||||
if (nextDocumentId) {
|
||||
window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map();
|
||||
window.__mnoteLocalFolderSelfChangeSuppressions.set(nextDocumentId, Date.now() + 5000);
|
||||
persistLocalFolderSelfChangeSuppression(nextDocumentId, Date.now() + 5000);
|
||||
}
|
||||
if (nextDocumentId && currentSourceKind() === 'local_folder') {
|
||||
await refreshLocalFolderSidebarSnapshot();
|
||||
@@ -2055,7 +2229,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</ul>'
|
||||
: '';
|
||||
var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -2104,6 +2278,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return { objectKind: objectKind, documentId: documentId, blockId: null, assetId: assetId };
|
||||
}
|
||||
|
||||
function fileOwnerDocumentId(item, fallbackDocumentId, objectIdentity) {
|
||||
var identity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : fileObjectIdentity(item);
|
||||
var owner = String(identity && identity.documentId || '').trim();
|
||||
if (owner) return owner;
|
||||
var assetId = fileAssetId(item);
|
||||
var localPath = localFilePathFromAssetId(assetId);
|
||||
if (localPath && localPath.indexOf('/') > 0) {
|
||||
var parts = localPath.split('/');
|
||||
var bundleName = parts[0] || '';
|
||||
if (bundleName) return 'local-md:' + bundleName + '~2F' + bundleName + '.md';
|
||||
}
|
||||
return String(fallbackDocumentId || '').trim();
|
||||
}
|
||||
|
||||
function objectIdentityAttr(identity) {
|
||||
try {
|
||||
return JSON.stringify(identity || {});
|
||||
@@ -2148,6 +2336,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var assetId = fileAssetId(item);
|
||||
var relativePath = fileWorkspaceRelativePath(item);
|
||||
var objectIdentity = fileObjectIdentity(item);
|
||||
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
||||
var iconKind = iconKindOf(item);
|
||||
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
||||
? fileTreePageTitle(rawTitle)
|
||||
@@ -2166,7 +2355,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var childHtml = expandable
|
||||
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<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(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<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(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -2401,7 +2590,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||
href: buildLocalFileOpenUrl(localFilePath, false),
|
||||
officeUrl: localOfficeUrl
|
||||
officeUrl: localOfficeUrl,
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
});
|
||||
if (!opened) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
@@ -2498,7 +2688,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
href: String(input && input.href || buildLocalFileOpenUrl(relativePath, false) || '').trim(),
|
||||
officeUrl: String(input && input.officeUrl || '').trim(),
|
||||
documentId: String(input && input.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim()
|
||||
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
|
||||
paneRole: String(input && input.paneRole || 'primary').trim() || 'primary'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2536,11 +2727,23 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var forceEditMode = openTarget === 'edit-mode';
|
||||
if (openTarget === 'side') {
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') {
|
||||
var sideLocalFilePath = localFilePathFromAssetId(assetId);
|
||||
var sideRootUri = String(detail.rootUri || currentRootUri() || '').trim();
|
||||
var sideFileName = String(detail.title || detail.fileName || (sideLocalFilePath ? sideLocalFilePath.split('/').pop() : '') || assetId || '').trim();
|
||||
var sideKind = String(detail.iconKind || detail.assetType || fileTreeIconKindForFileName(sideFileName) || 'file').trim();
|
||||
var sideHref = sideLocalFilePath ? buildLocalFileOpenUrl(sideLocalFilePath, false) : '';
|
||||
var sideOfficeUrl = sideLocalFilePath ? buildLocalOnlyOfficeOpenUrl(sideLocalFilePath, sideFileName, String(detail.documentId || currentDocumentId() || '').trim(), assetId, 'view') : '';
|
||||
if (sideOfficeUrl) sideKind = 'office';
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({
|
||||
objectIdentity: String(detail.objectIdentity || detail.assetId || ''),
|
||||
objectIdentity: sideLocalFilePath && sideRootUri ? 'resource:file:' + sideRootUri + ':' + sideLocalFilePath : String(detail.objectIdentity || detail.assetId || ''),
|
||||
assetId: assetId,
|
||||
title: String(detail.title || detail.fileName || assetId || ''),
|
||||
kind: String(detail.iconKind || detail.assetType || 'file'),
|
||||
title: sideFileName,
|
||||
fileName: sideFileName,
|
||||
kind: sideKind,
|
||||
path: sideLocalFilePath,
|
||||
rootUri: sideRootUri,
|
||||
href: sideHref,
|
||||
officeUrl: sideOfficeUrl,
|
||||
documentId: String(detail.documentId || currentDocumentId() || ''),
|
||||
workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || '')
|
||||
});
|
||||
@@ -2571,7 +2774,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
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, forceEditMode ? 'edit' : 'view');
|
||||
var requestedOfficeMode = forceNewWindow || forceEditMode ? 'edit' : 'view';
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, requestedOfficeMode);
|
||||
if (localOfficeUrl) {
|
||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
@@ -2587,7 +2791,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
if (localFileUrl) {
|
||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
||||
var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);
|
||||
if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
title: localFileName,
|
||||
kind: fileTreeIconKindForFileName(localFileName),
|
||||
@@ -2932,6 +3137,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '';
|
||||
}
|
||||
|
||||
function shouldOpenLocalResourceInNewWindow(fileName) {
|
||||
var ext = attachmentExtensionFromFileName(fileName);
|
||||
return ext === 'pdf';
|
||||
}
|
||||
|
||||
function isLocalUploadedAsset(asset) {
|
||||
var id = String(asset && asset.id || '').trim();
|
||||
return String(asset && asset.sourceKind || '').trim() === 'local_folder' || id.indexOf('local:asset:') === 0;
|
||||
@@ -3492,10 +3702,99 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return true;
|
||||
}
|
||||
|
||||
async function insertUploadedAssetIntoEditor(asset) {
|
||||
var editorRoot = document.querySelector('.editor-surface .ProseMirror');
|
||||
function resolveEditorUploadContext(detail) {
|
||||
var root = null;
|
||||
var selector = detail && detail.editorRootSelector ? String(detail.editorRootSelector) : '';
|
||||
if (selector) {
|
||||
try {
|
||||
var selected = document.querySelector(selector);
|
||||
if (selected instanceof HTMLElement) root = selected;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!root && window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) {
|
||||
root = window.__mnoteIntendedSlashRoot;
|
||||
}
|
||||
if (!root && window.__mnoteLastEditorUploadRoot instanceof HTMLElement && window.__mnoteLastEditorUploadRoot.isConnected) {
|
||||
root = window.__mnoteLastEditorUploadRoot;
|
||||
}
|
||||
if (!root && document.activeElement instanceof Element) {
|
||||
root = editorUploadRootFromElement(document.activeElement);
|
||||
}
|
||||
if (!root) {
|
||||
var focused = document.querySelector(EDITOR_UPLOAD_ROOT_SELECTOR + ' .ProseMirror:focus-within');
|
||||
root = editorUploadRootFromElement(focused);
|
||||
}
|
||||
if (!root) {
|
||||
root = document.querySelector('[data-editor-host-kind="leptos_tiptap_island"][data-pane-role="primary"]');
|
||||
}
|
||||
var pane = root instanceof Element ? root.closest('.document-pane[data-pane-role]') : null;
|
||||
var shell = root instanceof Element ? root.closest('.document-shell[data-document-id]') : null;
|
||||
return {
|
||||
root: root instanceof HTMLElement ? root : null,
|
||||
documentId: String(
|
||||
detail && detail.documentId
|
||||
|| (root instanceof HTMLElement && root.getAttribute('data-document-id'))
|
||||
|| (pane instanceof HTMLElement && pane.getAttribute('data-pane-document-id'))
|
||||
|| (shell instanceof HTMLElement && shell.getAttribute('data-document-id'))
|
||||
|| currentDocumentId()
|
||||
|| ''
|
||||
).trim(),
|
||||
workspaceId: String(
|
||||
detail && detail.workspaceId
|
||||
|| (root instanceof HTMLElement && root.getAttribute('data-workspace-id'))
|
||||
|| (pane instanceof HTMLElement && pane.getAttribute('data-pane-workspace-id'))
|
||||
|| (shell instanceof HTMLElement && shell.getAttribute('data-workspace-id'))
|
||||
|| resolveWorkspaceId(document.body)
|
||||
|| ''
|
||||
).trim()
|
||||
};
|
||||
}
|
||||
|
||||
function editorRootFromUploadOptions(options) {
|
||||
if (options && options.editorRoot instanceof HTMLElement) return options.editorRoot;
|
||||
if (window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) {
|
||||
return window.__mnoteIntendedSlashRoot;
|
||||
}
|
||||
if (window.__mnoteLastEditorUploadRoot instanceof HTMLElement && window.__mnoteLastEditorUploadRoot.isConnected) {
|
||||
return window.__mnoteLastEditorUploadRoot;
|
||||
}
|
||||
var focused = document.querySelector(EDITOR_UPLOAD_ROOT_SELECTOR + ' .ProseMirror:focus-within');
|
||||
return editorUploadRootFromElement(focused);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input, init, timeoutMs, label) {
|
||||
var controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
var timer = 0;
|
||||
try {
|
||||
if (controller) {
|
||||
timer = window.setTimeout(function() {
|
||||
controller.abort();
|
||||
}, Math.max(1000, Number(timeoutMs) || 15000));
|
||||
}
|
||||
var nextInit = Object.assign({}, init || {});
|
||||
if (controller) nextInit.signal = controller.signal;
|
||||
return await fetch(input, nextInit);
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') {
|
||||
throw new Error((label || '请求') + '超时');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertUploadedAssetIntoEditor(asset, targetRoot) {
|
||||
var editorRoot = targetRoot instanceof HTMLElement
|
||||
? targetRoot.querySelector('.editor-surface .ProseMirror')
|
||||
: document.querySelector('.editor-surface .ProseMirror');
|
||||
if (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot;
|
||||
var editor = editorRoot && editorRoot.editor;
|
||||
if (!editor || !editor.chain) return false;
|
||||
if (!editor || !editor.chain) {
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false');
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'editor_unavailable');
|
||||
return false;
|
||||
}
|
||||
var title = uploadedAssetTitle(asset);
|
||||
var url = localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset);
|
||||
var type = uploadedAssetType(asset);
|
||||
@@ -3525,28 +3824,42 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
mode: 'view'
|
||||
}))
|
||||
: href;
|
||||
var inserted = editor.chain().focus().insertContent({
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: storedHref,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: uploadedAttachmentClass(asset)
|
||||
}
|
||||
var inserted = editor.chain().focus().insertContent([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: storedHref,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: uploadedAttachmentClass(asset)
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}).run() === true;
|
||||
},
|
||||
{ type: 'paragraph' }
|
||||
]).focus('end').run() === true;
|
||||
if (inserted) {
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || '');
|
||||
document.documentElement.removeAttribute('data-mnote-last-upload-insert-error');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false');
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed');
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
if (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot;
|
||||
enhanceEditorAttachmentLinks();
|
||||
var selector = assetId
|
||||
? '.editor-surface .ProseMirror a[href*="' + cssEscape(assetId) + '"]'
|
||||
: '.editor-surface .ProseMirror a';
|
||||
var link = document.querySelector(selector);
|
||||
var link = targetRoot instanceof HTMLElement
|
||||
? targetRoot.querySelector(selector)
|
||||
: document.querySelector(selector);
|
||||
if (link instanceof HTMLElement) {
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||
@@ -3563,7 +3876,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
async function uploadFileToMediaAsset(file, plan, options) {
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
var rootUri = (new URLSearchParams(window.location.search).get('rootUri') || '').trim();
|
||||
var rootUri = currentRootUri();
|
||||
var documentId = String(plan && plan.targetDocumentId || currentDocumentId() || '').trim();
|
||||
var hasFolderTarget = plan && Object.prototype.hasOwnProperty.call(plan, 'targetRelativePath');
|
||||
if (!rootUri || (!documentId && !hasFolderTarget)) {
|
||||
@@ -3575,17 +3888,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (documentId) localForm.append('documentId', documentId);
|
||||
if (hasFolderTarget) localForm.append('targetRelativePath', String(plan.targetRelativePath || ''));
|
||||
localForm.append('kind', file && String(file.type || '').indexOf('image/') === 0 ? 'image' : 'attachment');
|
||||
var localResponse = await fetch('/api/local-folder/assets/upload', {
|
||||
var localResponse = await fetchWithTimeout('/api/local-folder/assets/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: localForm
|
||||
});
|
||||
}, 15000, '本地上传');
|
||||
var localPayload = await localResponse.json().catch(function() { return null; });
|
||||
if (!localResponse.ok || !localPayload || !localPayload.asset) {
|
||||
throw new Error(localPayload && localPayload.error ? localPayload.error : '上传失败');
|
||||
}
|
||||
if (options && options.insertIntoEditor) {
|
||||
await insertUploadedAssetIntoEditor(localPayload.asset);
|
||||
await insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options));
|
||||
}
|
||||
void refreshLocalFolderSidebarSnapshot();
|
||||
window.dispatchEvent(new CustomEvent('wolai:local-assets-changed', {
|
||||
@@ -3598,18 +3911,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
form.append('workspaceId', plan.workspaceId);
|
||||
form.append('documentId', plan.targetDocumentId);
|
||||
if (plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId);
|
||||
var response = await fetch('/api/media/upload', {
|
||||
var response = await fetchWithTimeout('/api/media/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form
|
||||
});
|
||||
}, 15000, '上传');
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload || !payload.asset) {
|
||||
throw new Error(payload && payload.error ? payload.error : '上传失败');
|
||||
}
|
||||
appendUploadedAssetRow(payload.asset, plan.targetDocumentId);
|
||||
if (options && options.insertIntoEditor) {
|
||||
await insertUploadedAssetIntoEditor(payload.asset);
|
||||
await insertUploadedAssetIntoEditor(payload.asset, editorRootFromUploadOptions(options));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('wolai:assets-changed', {
|
||||
detail: { docId: plan.targetDocumentId, asset: payload.asset, assetIds: [payload.asset.id] }
|
||||
@@ -3642,6 +3955,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function openEditorUploadFilePicker(detail) {
|
||||
var uploadContext = resolveEditorUploadContext(detail || {});
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = detail && detail.multiple !== false;
|
||||
@@ -3654,11 +3968,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var files = Array.from(input.files || []);
|
||||
input.remove();
|
||||
void uploadFilesWithResolvedTarget(files, {
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: uploadContext.workspaceId || resolveWorkspaceId(document.body),
|
||||
documentId: uploadContext.documentId || currentDocumentId(),
|
||||
targetRowId: null
|
||||
}, {
|
||||
insertIntoEditor: detail && detail.insertIntoEditor !== false
|
||||
insertIntoEditor: detail && detail.insertIntoEditor !== false,
|
||||
editorRoot: uploadContext.root
|
||||
});
|
||||
}, { once: true });
|
||||
input.click();
|
||||
@@ -3695,7 +4010,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
documentId: currentDocumentId(),
|
||||
targetRowId: null
|
||||
}, {
|
||||
insertIntoEditor: true
|
||||
insertIntoEditor: true,
|
||||
editorRoot: editorUploadRootFromElement(editorTarget)
|
||||
});
|
||||
}, true);
|
||||
|
||||
@@ -8361,14 +8677,47 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return Boolean(inferOnlyOfficeFileType(fileName, ''));
|
||||
}
|
||||
|
||||
function editorAttachmentPaneContext(link) {
|
||||
var pane = link && typeof link.closest === 'function' ? link.closest('[data-document-pane="true"]') : null;
|
||||
var roleHost = link && typeof link.closest === 'function' ? link.closest('[data-pane-role]') : null;
|
||||
var paneRole = (
|
||||
pane instanceof HTMLElement && pane.getAttribute('data-pane-role') === 'secondary'
|
||||
) || (
|
||||
roleHost instanceof HTMLElement && roleHost.getAttribute('data-pane-role') === 'secondary'
|
||||
) ? 'secondary' : 'primary';
|
||||
var paneDocumentId = '';
|
||||
var paneWorkspaceId = '';
|
||||
if (pane instanceof HTMLElement) {
|
||||
paneDocumentId = (pane.getAttribute('data-pane-document-id') || '').trim();
|
||||
paneWorkspaceId = (pane.getAttribute('data-pane-workspace-id') || '').trim();
|
||||
var shell = pane.querySelector('.document-shell[data-document-id]');
|
||||
if (!paneDocumentId && shell instanceof HTMLElement) paneDocumentId = (shell.getAttribute('data-document-id') || '').trim();
|
||||
if (!paneWorkspaceId && shell instanceof HTMLElement) paneWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim();
|
||||
}
|
||||
if (roleHost instanceof HTMLElement) {
|
||||
if (!paneDocumentId) paneDocumentId = (roleHost.getAttribute('data-document-id') || '').trim();
|
||||
if (!paneWorkspaceId) paneWorkspaceId = (roleHost.getAttribute('data-workspace-id') || '').trim();
|
||||
var roleHostShell = roleHost.matches('.document-shell') ? roleHost : roleHost.querySelector?.('.document-shell[data-document-id]');
|
||||
if (!paneDocumentId && roleHostShell instanceof HTMLElement) paneDocumentId = (roleHostShell.getAttribute('data-document-id') || '').trim();
|
||||
if (!paneWorkspaceId && roleHostShell instanceof HTMLElement) paneWorkspaceId = (roleHostShell.getAttribute('data-workspace-id') || '').trim();
|
||||
}
|
||||
return {
|
||||
paneRole: paneRole,
|
||||
documentId: paneDocumentId || currentDocumentId() || '',
|
||||
workspaceId: paneWorkspaceId || resolveWorkspaceId(document.body) || ''
|
||||
};
|
||||
}
|
||||
|
||||
function detailFromEditorAttachmentLink(link) {
|
||||
var rawHref = link instanceof HTMLAnchorElement ? link.href : '';
|
||||
var params = attachmentQueryParams(rawHref);
|
||||
var paneContext = editorAttachmentPaneContext(link);
|
||||
var localFilePath = localFileOpenPathFromHref(rawHref);
|
||||
var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件';
|
||||
var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, '');
|
||||
var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || (localFilePath ? 'local-file:' + localFilePath : '');
|
||||
var fileUrl = params.get('fileUrl') || '';
|
||||
var documentId = params.get('documentId') || paneContext.documentId || '';
|
||||
var href = rawHref;
|
||||
if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) {
|
||||
fileUrl = rawHref;
|
||||
@@ -8377,7 +8726,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
fileName: fileName,
|
||||
fileType: fileType,
|
||||
assetId: assetId,
|
||||
documentId: currentDocumentId() || '',
|
||||
documentId: documentId,
|
||||
userId: '',
|
||||
mode: 'view'
|
||||
});
|
||||
@@ -8391,8 +8740,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
title: fileName,
|
||||
fileType: fileType,
|
||||
assetId: assetId,
|
||||
documentId: params.get('documentId') || currentDocumentId() || '',
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: documentId,
|
||||
workspaceId: paneContext.workspaceId,
|
||||
paneRole: paneContext.paneRole,
|
||||
fileSize: (link ? link.getAttribute('data-file-size') : '') || ''
|
||||
};
|
||||
}
|
||||
@@ -8402,13 +8752,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var href = link.getAttribute('href') || '';
|
||||
var params = attachmentQueryParams(href);
|
||||
var localFilePath = localFileOpenPathFromHref(href);
|
||||
var paneContext = editorAttachmentPaneContext(link);
|
||||
var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || link.textContent || '';
|
||||
var className = link.getAttribute('class') || '';
|
||||
var shouldEnhance = isOnlyOfficeAttachmentHref(href)
|
||||
|| className.indexOf('mnote-uploaded-attachment-row') >= 0
|
||||
|| isOfficeFileName(fileName)
|
||||
|| Boolean(localFilePath && (isPdfAttachmentFileName(fileName) || isCodeAttachmentFileName(fileName)));
|
||||
|| Boolean(localFilePath);
|
||||
if (!shouldEnhance) return;
|
||||
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
|
||||
return;
|
||||
}
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : '');
|
||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||
@@ -8418,7 +8772,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
fileName: fileName || '未命名附件',
|
||||
fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''),
|
||||
assetId: assetId,
|
||||
documentId: params.get('documentId') || currentDocumentId() || '',
|
||||
documentId: params.get('documentId') || paneContext.documentId || '',
|
||||
userId: params.get('userId') || '',
|
||||
mode: params.get('mode') || 'view'
|
||||
}));
|
||||
@@ -8435,6 +8789,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink);
|
||||
void healLegacyOfficeAttachmentParagraphs();
|
||||
}
|
||||
window.__mnoteEnhanceEditorAttachmentLinks = function() {
|
||||
observeEditorAttachmentRoots();
|
||||
enhanceEditorAttachmentLinks();
|
||||
};
|
||||
|
||||
function ensureAttachmentActions() {
|
||||
var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||
@@ -8486,7 +8844,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: detail.assetId,
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||
href: buildLocalFileOpenUrl(localFilePath, false)
|
||||
href: buildLocalFileOpenUrl(localFilePath, false),
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
}).then(function(opened) {
|
||||
if (!opened) window.open(buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
@@ -8510,7 +8869,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
kind: 'office',
|
||||
officeUrl: detail.href,
|
||||
documentId: detail.documentId || '',
|
||||
workspaceId: detail.workspaceId || ''
|
||||
workspaceId: detail.workspaceId || '',
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -8582,7 +8942,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
kind: 'office',
|
||||
officeUrl: href,
|
||||
documentId: detail.documentId || '',
|
||||
workspaceId: detail.workspaceId || ''
|
||||
workspaceId: detail.workspaceId || '',
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
});
|
||||
if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return didOpenEditTab;
|
||||
@@ -8737,11 +9098,51 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
enhanceEditorAttachmentLinks();
|
||||
var attachmentObserver = new MutationObserver(function() { enhanceEditorAttachmentLinks(); });
|
||||
attachmentObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
var attachmentEnhanceFrame = 0;
|
||||
var attachmentEditorObserver = null;
|
||||
var attachmentObservedEditors = typeof WeakSet === 'function' ? new WeakSet() : null;
|
||||
function scheduleEditorAttachmentEnhance() {
|
||||
if (attachmentEnhanceFrame) return;
|
||||
attachmentEnhanceFrame = window.requestAnimationFrame(function() {
|
||||
attachmentEnhanceFrame = 0;
|
||||
enhanceEditorAttachmentLinks();
|
||||
observeEditorAttachmentRoots();
|
||||
});
|
||||
}
|
||||
function addedNodeMayContainEditorAttachmentLink(node) {
|
||||
return node instanceof HTMLElement && (
|
||||
node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div')
|
||||
|| node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href]')
|
||||
);
|
||||
}
|
||||
function observeEditorAttachmentRoots() {
|
||||
if (!attachmentEditorObserver) return;
|
||||
document.querySelectorAll('.editor-surface .ProseMirror').forEach(function(editor) {
|
||||
if (!(editor instanceof HTMLElement)) return;
|
||||
if (attachmentObservedEditors && attachmentObservedEditors.has(editor)) return;
|
||||
if (attachmentObservedEditors) attachmentObservedEditors.add(editor);
|
||||
attachmentEditorObserver.observe(editor, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
attachmentEditorObserver = new MutationObserver(function(records) {
|
||||
var shouldEnhance = Array.isArray(records) && records.some(function(record) {
|
||||
if (!record || record.type !== 'childList') return false;
|
||||
return Array.from(record.addedNodes || []).some(function(node) {
|
||||
return addedNodeMayContainEditorAttachmentLink(node);
|
||||
});
|
||||
});
|
||||
if (!shouldEnhance) return;
|
||||
scheduleEditorAttachmentEnhance();
|
||||
});
|
||||
attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
observeEditorAttachmentRoots();
|
||||
window.addEventListener('mnote:editor-attachment-links-changed', function() {
|
||||
window.__mnoteEnhanceEditorAttachmentLinks();
|
||||
scheduleEditorAttachmentEnhance();
|
||||
});
|
||||
|
||||
function interceptEditorAttachmentLink(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -8750,7 +9151,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function suppressEditorAttachmentLinkDefault(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -8761,7 +9162,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.addEventListener('click', interceptEditorAttachmentLink, true);
|
||||
|
||||
document.addEventListener('mouseover', function(event) {
|
||||
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
|
||||
enhanceEditorAttachmentLink(link);
|
||||
@@ -8769,7 +9170,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', function(event) {
|
||||
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
var next = event.relatedTarget;
|
||||
var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||
@@ -8807,7 +9208,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||
e.preventDefault();
|
||||
openEditorAttachmentLink(editorAttachmentLink);
|
||||
@@ -9170,6 +9571,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var rowId = fileRow.getAttribute('data-row-id') || '';
|
||||
var rowKind = fileRow.getAttribute('data-row-kind') || '';
|
||||
var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || '';
|
||||
var ownerDocumentId = fileRow.getAttribute('data-owner-document-id') || documentId;
|
||||
var assetId = fileRow.getAttribute('data-asset-id') || '';
|
||||
var assetType = '';
|
||||
var kindBadge = fileRow.querySelector('.tree-kind-badge');
|
||||
@@ -9205,14 +9607,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
e.preventDefault();
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
|
||||
var objectIdentity = readFileTreeObjectIdentity(fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
|
||||
} else if (assetId) {
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow), openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9264,7 +9666,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', function(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -9447,6 +9849,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
updatePageSettingsTriggerState();
|
||||
updatePageAiTriggerState();
|
||||
ensureHistorySnapshotsSeeded();
|
||||
installWorkspaceSidebarResizer();
|
||||
}
|
||||
|
||||
window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot;
|
||||
@@ -10143,6 +10546,7 @@ pub fn PageLayout(
|
||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||
<script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script>
|
||||
</aside>
|
||||
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
|
||||
<div class="mnote-main">
|
||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||
<div class="wolai-topbar-left">
|
||||
@@ -10180,6 +10584,27 @@ pub fn PageLayout(
|
||||
mod tests {
|
||||
use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS};
|
||||
|
||||
fn js_function_body(source: &str, name: &str) -> String {
|
||||
let marker = format!("function {name}(");
|
||||
let start = source.find(&marker).expect("js function exists");
|
||||
let rest = &source[start..];
|
||||
let brace = rest.find('{').expect("js function body starts");
|
||||
let mut depth = 0usize;
|
||||
let mut end = None;
|
||||
for (offset, ch) in rest[brace..].char_indices() {
|
||||
if ch == '{' {
|
||||
depth += 1;
|
||||
} else if ch == '}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(brace + offset + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
rest[..end.expect("js function body ends")].to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
|
||||
@@ -10255,6 +10680,10 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-roots"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-root"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("已授权文件夹"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("fetch('/api/user/access-policy'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10412,8 +10841,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder() {
|
||||
let current_document_function = js_function_body(SIDEBAR_TREE_JS, "currentDocumentId");
|
||||
let upload_function = js_function_body(SIDEBAR_TREE_JS, "uploadFileToMediaAsset");
|
||||
assert!(SIDEBAR_TREE_JS.contains("currentSourceKind() === 'local_folder'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/assets/upload"));
|
||||
assert!(
|
||||
upload_function.contains("var rootUri = currentRootUri();"),
|
||||
"本地上传必须复用 currentRootUri(),否则 / 页面由 body dataset 提供 rootUri 时 .md 上传会失败"
|
||||
);
|
||||
assert!(
|
||||
current_document_function.contains("params.get('pageId')"),
|
||||
"SQLite/local-first 根入口可能通过 pageId 或 DOM 暴露当前页面,不能只解析 /documents/:id"
|
||||
);
|
||||
assert!(
|
||||
current_document_function.contains("data-pane-document-id")
|
||||
&& current_document_function.contains("data-document-id"),
|
||||
"主编辑器上传应能从当前文档 DOM 回退解析 documentId"
|
||||
);
|
||||
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)"));
|
||||
@@ -10422,6 +10866,11 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("fileTreeIconKindForFileName(title)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function resolveEditorUploadContext"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("__mnoteLastEditorUploadRoot"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-pane-role=\"primary\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options))"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("editorRoot: uploadContext.root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10444,6 +10893,47 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("isLocalAsset ? onlyOfficeUrl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_local_folder_authorized_root_normalizes_plain_path_root_uri() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function normalizeGrantedLocalFolderRootUri(grant)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (rootPath) return pathToFileRootUri(rootPath);"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (rootUri) return pathToFileRootUri(rootUri);"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("var rootUri = normalizeGrantedLocalFolderRootUri(grant);")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_JS.contains("function isDefaultWorkspaceAutoGrant(grant)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isDefaultWorkspaceAutoGrant(grant)) return;"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootLabel(rootUri)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("button.textContent = recentLocalRootLabel(rootUri);"));
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("button.textContent = rootUri.replace(/^file:\\/\\//, '')")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_runtime_supports_resizable_filetree_and_hover_titles() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function installWorkspaceSidebarResizer()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-resizer"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote.workspace.sidebarWidth.v1"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("shell.style.setProperty('--mnote-sidebar-width'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("installWorkspaceSidebarResizer();"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(r#"" title="' + escapeHtml(title) + '""#));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains(r#"class="tree-link-title" title="' + escapeHtml(title) + '""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_runtime_opens_local_pdf_assets_in_browser_tab_by_default() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function shouldOpenLocalResourceInNewWindow(fileName)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("return ext === 'pdf';"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab'"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_contains_dev_hot_reload_client() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload"));
|
||||
@@ -10628,6 +11118,29 @@ mod tests {
|
||||
assert!(table_loop.contains("/api/tables/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_asset_open_uses_owner_document_id() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains(
|
||||
"var ownerDocumentId = fileRow.getAttribute('data-owner-document-id') || documentId;"
|
||||
),
|
||||
"打开资源行时必须优先使用资源 owner document,不能用当前页面 documentId"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("documentId: ownerDocumentId || null"),
|
||||
"tree.asset.open detail 应携带资源 owner document"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("data-owner-document-id=\"' + escapeHtml(ownerDocumentId)"),
|
||||
"SSR filetree 行应输出 data-owner-document-id"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS
|
||||
.contains("return 'local-md:' + bundleName + '~2F' + bundleName + '.md';"),
|
||||
"local_folder bundle 资源应从 local-file 路径推导 owner markdown document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
|
||||
|
||||
@@ -232,7 +232,8 @@ a:hover {
|
||||
}
|
||||
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar,
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar {
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar,
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -240,13 +241,38 @@ a:hover {
|
||||
--wolai-bg-sidebar: #F7F7F6;
|
||||
--wolai-bg-selected: #F8E6E7;
|
||||
--wolai-accent-red: #E0525B;
|
||||
--mnote-sidebar-width: 288px;
|
||||
}
|
||||
|
||||
.wolai-sidebar {
|
||||
width: 288px;
|
||||
width: var(--mnote-sidebar-width);
|
||||
background: var(--wolai-bg-sidebar);
|
||||
}
|
||||
|
||||
.mnote-sidebar-resizer {
|
||||
display: block;
|
||||
width: 6px;
|
||||
min-height: 100vh;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mnote-sidebar-resizer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.mnote-sidebar-resizer:hover::before,
|
||||
html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
|
||||
background: rgba(37, 99, 235, 0.42);
|
||||
}
|
||||
|
||||
.wolai-sidebar-header {
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
@@ -345,6 +371,33 @@ a:hover {
|
||||
color: #1D4ED8;
|
||||
}
|
||||
|
||||
.mnote-local-folder-dialog__recent {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-local-folder-dialog__recent button {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 7px 9px;
|
||||
color: var(--wolai-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mnote-local-folder-dialog__recent button:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-account-menu{inset-inline-start:auto;right:8px;top:calc(100% - 10px);width:194px}.mnote-account-menu__item{display:flex;align-items:center;gap:10px}.mnote-account-menu__logout{color:#C2410C}.mnote-account-menu__logout:hover{background:#FFF7ED}.mnote-account-menu__error{padding:6px 8px 2px;color:#B91C1C;font-size:12px}.mnote-profile-dialog{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-profile-dialog__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-profile-dialog__panel{position:relative;width:min(440px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-profile-dialog__header,.mnote-profile-dialog__identity,.mnote-profile-dialog__list>div,.mnote-profile-dialog__list dd{display:flex;align-items:center}.mnote-profile-dialog__header{justify-content:space-between;margin-bottom:18px}.mnote-profile-dialog__eyebrow,.mnote-profile-dialog__identity span,.mnote-profile-dialog__list dt{color:var(--wolai-text-secondary);font-size:13px}.mnote-profile-dialog__header h2{font-size:20px}.mnote-profile-dialog__close,.mnote-profile-dialog__list button{border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-profile-dialog__close{width:32px;height:32px}.mnote-profile-dialog__identity{gap:12px;padding:12px;border-radius:8px;background:var(--wolai-bg-sidebar)}.mnote-profile-dialog__avatar{width:36px;height:36px;display:grid;place-items:center;border-radius:6px;background:#D6545D;color:#fff;font-weight:650}.mnote-profile-dialog__list{margin-top:16px}.mnote-profile-dialog__list>div{justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--wolai-border)}.mnote-profile-dialog__list dd{min-width:0;gap:8px;max-width:280px;font-size:13px;text-align:right;overflow-wrap:anywhere}.mnote-profile-dialog__list code{font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px;white-space:normal}
|
||||
|
||||
.wolai-quick-actions {
|
||||
@@ -792,10 +845,15 @@ a:hover {
|
||||
|
||||
.mnote-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mnote-content:has(.document-workspace) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== 首页 ===== */
|
||||
.mnote-home {
|
||||
max-width: 720px;
|
||||
@@ -1311,7 +1369,7 @@ body {
|
||||
|
||||
.mnote-sidebar,
|
||||
.wolai-sidebar {
|
||||
width: 248px;
|
||||
width: var(--mnote-sidebar-width, 248px);
|
||||
background: var(--atelier-sidebar);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
@@ -1798,7 +1856,8 @@ body {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
|
||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row,
|
||||
.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 7px !important;
|
||||
@@ -1812,7 +1871,8 @@ body {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before {
|
||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before,
|
||||
.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before {
|
||||
content: "" !important;
|
||||
display: inline-block !important;
|
||||
width: 18px !important;
|
||||
@@ -1900,7 +1960,7 @@ body {
|
||||
background: var(--atelier-document);
|
||||
}
|
||||
|
||||
.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row strong,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}}
|
||||
.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row>div{min-width:0}.mnote-admin-policy-row strong,.mnote-admin-policy-root-link{overflow-wrap:anywhere}.mnote-admin-policy-row strong,.mnote-admin-policy-root-link,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-root-link{color:var(--wolai-text-primary);font-weight:650;text-decoration:none}.mnote-admin-policy-root-link:hover{text-decoration:underline}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}}
|
||||
|
||||
.mnote-trash-workbench {
|
||||
width: min(860px, calc(100vw - 64px));
|
||||
@@ -2217,9 +2277,12 @@ body {
|
||||
--mnote-secondary-pane-resizer-width: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] {
|
||||
@@ -2228,6 +2291,28 @@ body {
|
||||
|
||||
.document-main-editor-group {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mnote-main-tab-panels {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mnote-main-tab-panels > [data-mnote-page-tab-panel] {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mnote-main-tab-strip {
|
||||
@@ -2376,8 +2461,20 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-host {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-panel-root {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-panel {
|
||||
min-height: calc(100vh - 76px);
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-frame,
|
||||
@@ -2439,6 +2536,39 @@ body {
|
||||
color: var(--color-basic-600, #9B9A97);
|
||||
}
|
||||
|
||||
.mnote-resource-tab-mindmap-shell {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-mindmap-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .mnote-resource-tab-mindmap-shell,
|
||||
[data-testid="mnote-secondary-editor-tab-host"] .mnote-resource-tab-mindmap-shell {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-mindmap-shell [data-testid="mnote-mindmap-editor-root"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-editor-root {
|
||||
min-height: calc(100vh - 112px);
|
||||
}
|
||||
@@ -2689,6 +2819,10 @@ body {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .editor-surface {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
@@ -2696,6 +2830,13 @@ body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .editor-surface {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror,
|
||||
.ProseMirror {
|
||||
color: var(--atelier-text);
|
||||
@@ -2711,6 +2852,13 @@ body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
|
||||
width: 100% !important;
|
||||
min-height: 280px;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell {
|
||||
left: -32px !important;
|
||||
z-index: 80;
|
||||
@@ -2733,17 +2881,32 @@ body {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror ol {
|
||||
margin: 4px 0 8px;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul,
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol {
|
||||
margin: 4px 0 8px;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror li {
|
||||
margin: 2px 0;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror li {
|
||||
margin: 2px 0;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 2px 0 2px 14px;
|
||||
@@ -2751,6 +2914,13 @@ body {
|
||||
color: #5F5B56;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 2px 0 2px 14px;
|
||||
border-left: 3px solid #D9D6D0;
|
||||
color: #5F5B56;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -2759,6 +2929,14 @@ body {
|
||||
accent-color: #2EA44F;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0 8px 0 0;
|
||||
vertical-align: -3px;
|
||||
accent-color: #2EA44F;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror h1,
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror h2,
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror h3 {
|
||||
@@ -2766,6 +2944,13 @@ body {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h1,
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h2,
|
||||
[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h3 {
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mnote-workspace-active-state {
|
||||
padding-top: 10px;
|
||||
}
|
||||
@@ -2846,6 +3031,13 @@ body {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror,
|
||||
.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p,
|
||||
.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror li {
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
|
||||
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
|
||||
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol,
|
||||
@@ -2853,6 +3045,13 @@ body {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p,
|
||||
.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul,
|
||||
.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol,
|
||||
.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
|
||||
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
|
||||
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol,
|
||||
@@ -2860,20 +3059,39 @@ body {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p,
|
||||
.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul,
|
||||
.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol,
|
||||
.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.document-shell[data-page-font="song"] .document-title-input,
|
||||
.document-shell[data-page-font="song"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
}
|
||||
|
||||
.document-shell[data-page-font="song"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
}
|
||||
|
||||
.document-shell[data-page-font="kai"] .document-title-input,
|
||||
.document-shell[data-page-font="kai"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
}
|
||||
|
||||
.document-shell[data-page-font="kai"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
}
|
||||
|
||||
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
|
||||
counter-reset: mnote-heading;
|
||||
}
|
||||
|
||||
.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
|
||||
counter-reset: mnote-heading;
|
||||
}
|
||||
|
||||
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h1::before,
|
||||
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h2::before,
|
||||
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h3::before {
|
||||
@@ -2883,6 +3101,15 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h1::before,
|
||||
.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h2::before,
|
||||
.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h3::before {
|
||||
counter-increment: mnote-heading;
|
||||
content: counter(mnote-heading) ". ";
|
||||
color: #8B8782;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wolai-page-settings-popover {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
@@ -4025,6 +4252,10 @@ button.wolai-page-ai-message-text {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.mnote-sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-topbar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -4091,6 +4322,10 @@ button.wolai-page-ai-message-text {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-shell {
|
||||
padding: 36px 20px 112px;
|
||||
}
|
||||
@@ -4141,6 +4376,9 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains("#mnote-editor-island"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-search-island"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-mindmap-island"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-sidebar-width"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar-resizer"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-local-folder-dialog__recent button"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4176,6 +4414,15 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_policy_rows_wrap_long_grant_values() {
|
||||
assert!(MNOTE_CSS.contains(
|
||||
".mnote-admin-policy-row strong,.mnote-admin-policy-root-link{overflow-wrap:anywhere"
|
||||
));
|
||||
assert!(MNOTE_CSS.contains(".mnote-admin-policy-row>div{min-width:0"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-admin-policy-root-link:hover"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_prosemirror_styles() {
|
||||
assert!(MNOTE_CSS.contains(".ProseMirror"));
|
||||
|
||||
@@ -90,7 +90,7 @@ fn render_filetree_row(
|
||||
};
|
||||
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -217,6 +217,8 @@ mod tests {
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("title=\"首页 <安全>\""));
|
||||
assert!(html.contains("title=\"思维导图.json\""));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user