chore: align mvp design governance
- 统一 local-first MVP 后阶段架构口径,补充 process 执行总序和 Reasonix 协作记录 - 归档已完成的 design checklist,标注参考型 process,更新 AGENTS/REASONIX/架构文档 - 补充文件树/主编辑器下载与上下文菜单相关实现、bug 记录和 smoke 脚本 验证:git diff --check;codegraph sync .;cargo test -p mnote-web;node --check scripts/task476-filetree-editor-context-menu-download-smoke.js
This commit is contained in:
@@ -204,6 +204,7 @@ struct LocalAssetUploadFields {
|
||||
file: LocalUploadFile,
|
||||
root_uri: String,
|
||||
document_id: String,
|
||||
target_relative_path: Option<String>,
|
||||
kind: String,
|
||||
}
|
||||
|
||||
@@ -2314,12 +2315,21 @@ pub async fn upload_local_markdown_asset(
|
||||
let fields = read_local_asset_upload_multipart(multipart).await?;
|
||||
ensure_local_workspace_access(&context, &fields.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let asset = write_local_markdown_asset(
|
||||
&fields.root_uri,
|
||||
&fields.document_id,
|
||||
&fields.kind,
|
||||
fields.file,
|
||||
)?;
|
||||
let asset = if fields.target_relative_path.is_some() || fields.document_id.trim().is_empty() {
|
||||
write_local_folder_file_upload(
|
||||
&fields.root_uri,
|
||||
fields.target_relative_path.as_deref().unwrap_or(""),
|
||||
&fields.kind,
|
||||
fields.file,
|
||||
)?
|
||||
} else {
|
||||
write_local_markdown_asset(
|
||||
&fields.root_uri,
|
||||
&fields.document_id,
|
||||
&fields.kind,
|
||||
fields.file,
|
||||
)?
|
||||
};
|
||||
Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset }))))
|
||||
}
|
||||
|
||||
@@ -2329,7 +2339,33 @@ pub async fn open_local_file(
|
||||
) -> Result<(StatusCode, HeaderMap, Vec<u8>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
|
||||
let target = resolve_local_open_path(&query.root_uri, &query.path)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
if target.is_dir() {
|
||||
if !query.download.unwrap_or(false) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_open_is_directory",
|
||||
"不能直接读取本地目录",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let bytes = build_local_directory_tar_archive(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_directory_download_failed",
|
||||
format!("无法打包本地目录 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/x-tar"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
content_disposition_attachment_for_filename(&local_directory_tar_filename(&target)),
|
||||
);
|
||||
return Ok((StatusCode::OK, headers, bytes));
|
||||
}
|
||||
let bytes = fs::read(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_file_open_read_failed",
|
||||
@@ -2337,12 +2373,11 @@ pub async fn open_local_file(
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, content_type_for_path(&target));
|
||||
if query.download.unwrap_or(false) {
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment"),
|
||||
content_disposition_attachment_for_path(&target),
|
||||
);
|
||||
}
|
||||
Ok((StatusCode::OK, headers, bytes))
|
||||
@@ -2485,7 +2520,7 @@ pub async fn write_local_resource(
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
fn resolve_local_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -2519,6 +2554,11 @@ fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<P
|
||||
"本地文件路径不能越过 root",
|
||||
));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let target = resolve_local_open_path(root_uri, relative_path)?;
|
||||
if target.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_open_is_directory",
|
||||
@@ -2534,6 +2574,7 @@ async fn read_local_asset_upload_multipart(
|
||||
let mut file: Option<LocalUploadFile> = None;
|
||||
let mut root_uri = String::new();
|
||||
let mut document_id = String::new();
|
||||
let mut target_relative_path: Option<String> = None;
|
||||
let mut kind = String::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
||||
@@ -2583,6 +2624,9 @@ async fn read_local_asset_upload_multipart(
|
||||
match name.as_str() {
|
||||
"rootUri" => root_uri = value.trim().to_string(),
|
||||
"documentId" => document_id = value.trim().to_string(),
|
||||
"targetRelativePath" | "targetDirectoryPath" => {
|
||||
target_relative_path = Some(value.trim().to_string())
|
||||
}
|
||||
"kind" => kind = value.trim().to_string(),
|
||||
_ => {}
|
||||
}
|
||||
@@ -2591,7 +2635,10 @@ async fn read_local_asset_upload_multipart(
|
||||
let file = file.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file")
|
||||
})?;
|
||||
if file.bytes.is_empty() || root_uri.is_empty() || document_id.is_empty() {
|
||||
if file.bytes.is_empty()
|
||||
|| root_uri.is_empty()
|
||||
|| (document_id.is_empty() && target_relative_path.is_none())
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_asset_upload_required_missing",
|
||||
"缺少必要参数",
|
||||
@@ -2601,10 +2648,85 @@ async fn read_local_asset_upload_multipart(
|
||||
file,
|
||||
root_uri,
|
||||
document_id,
|
||||
target_relative_path,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn write_local_folder_file_upload(
|
||||
root_uri: &str,
|
||||
target_relative_path: &str,
|
||||
kind: &str,
|
||||
file: LocalUploadFile,
|
||||
) -> Result<Value, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
})?;
|
||||
if !canonical_root.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_not_directory",
|
||||
"本地 rootUri 必须指向目录",
|
||||
));
|
||||
}
|
||||
let target_relative_path = target_relative_path.trim();
|
||||
let requested = if target_relative_path.is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
Path::new(target_relative_path)
|
||||
};
|
||||
if requested.is_absolute()
|
||||
|| requested
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_upload_root_escape",
|
||||
"上传目标目录不能越过 root",
|
||||
));
|
||||
}
|
||||
let target_dir = canonical_root
|
||||
.join(requested)
|
||||
.canonicalize()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_upload_target_missing",
|
||||
format!("找不到上传目标目录: {error}"),
|
||||
)
|
||||
})?;
|
||||
if !target_dir.starts_with(&canonical_root) || !target_dir.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_upload_target_invalid",
|
||||
"上传目标必须是本地 root 内的目录",
|
||||
));
|
||||
}
|
||||
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
||||
let target = next_available_raw_path(&target_dir, &sanitized_name);
|
||||
fs::write(&target, &file.bytes).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_upload_write_failed",
|
||||
format!("无法写入本地文件 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
|
||||
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
||||
Ok(json!({
|
||||
"id": format!("local-file:{root_relative_path}"),
|
||||
"asset_type": asset_type,
|
||||
"file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name),
|
||||
"mime_type": file.content_type,
|
||||
"file_size": file.bytes.len(),
|
||||
"file_url": root_relative_path,
|
||||
"sourcePath": root_relative_path,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": file_uri_for_path(&canonical_root),
|
||||
"rootRelativePath": root_relative_path,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn write_local_markdown_asset(
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
@@ -6532,6 +6654,217 @@ fn content_type_for_path(path: &Path) -> HeaderValue {
|
||||
HeaderValue::from_static(content_type)
|
||||
}
|
||||
|
||||
fn percent_encode_content_disposition_filename(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.as_bytes() {
|
||||
match *byte {
|
||||
b'A'..=b'Z'
|
||||
| b'a'..=b'z'
|
||||
| b'0'..=b'9'
|
||||
| b'!'
|
||||
| b'#'
|
||||
| b'$'
|
||||
| b'&'
|
||||
| b'+'
|
||||
| b'-'
|
||||
| b'.'
|
||||
| b'^'
|
||||
| b'_'
|
||||
| b'`'
|
||||
| b'|'
|
||||
| b'~' => encoded.push(*byte as char),
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn content_disposition_attachment_for_path(path: &Path) -> HeaderValue {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("download");
|
||||
content_disposition_attachment_for_filename(filename)
|
||||
}
|
||||
|
||||
fn content_disposition_attachment_for_filename(filename: &str) -> HeaderValue {
|
||||
let filename = filename.trim();
|
||||
let filename = if filename.is_empty() {
|
||||
"download"
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
let ascii_fallback = filename
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ' ') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let ascii_fallback = if ascii_fallback.trim().is_empty() {
|
||||
"download".to_string()
|
||||
} else {
|
||||
ascii_fallback
|
||||
};
|
||||
let quoted = ascii_fallback.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let encoded = percent_encode_content_disposition_filename(filename);
|
||||
HeaderValue::from_str(&format!(
|
||||
"attachment; filename=\"{quoted}\"; filename*=UTF-8''{encoded}"
|
||||
))
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("attachment"))
|
||||
}
|
||||
|
||||
fn local_directory_tar_filename(path: &Path) -> String {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("folder");
|
||||
format!("{name}.tar")
|
||||
}
|
||||
|
||||
fn tar_octal(value: u64, width: usize) -> Vec<u8> {
|
||||
let mut encoded = format!("{value:o}").into_bytes();
|
||||
let max_digits = width.saturating_sub(1);
|
||||
if encoded.len() > max_digits {
|
||||
encoded = vec![b'7'; max_digits];
|
||||
}
|
||||
let mut out = vec![b'0'; max_digits.saturating_sub(encoded.len())];
|
||||
out.extend(encoded);
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
|
||||
fn split_tar_path(path: &str) -> Result<(&str, &str), std::io::Error> {
|
||||
let bytes = path.as_bytes();
|
||||
if bytes.len() <= 100 {
|
||||
return Ok(("", path));
|
||||
}
|
||||
let mut best: Option<usize> = None;
|
||||
for (index, ch) in path.char_indices() {
|
||||
if ch == '/' {
|
||||
let prefix_len = path[..index].as_bytes().len();
|
||||
let name_len = path[index + 1..].as_bytes().len();
|
||||
if prefix_len <= 155 && name_len <= 100 {
|
||||
best = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(index) = best {
|
||||
return Ok((&path[..index], &path[index + 1..]));
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"tar entry path is too long",
|
||||
))
|
||||
}
|
||||
|
||||
fn append_tar_header(
|
||||
out: &mut Vec<u8>,
|
||||
path: &str,
|
||||
size: u64,
|
||||
typeflag: u8,
|
||||
mode: u64,
|
||||
mtime: u64,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let normalized = path.trim_start_matches('/').replace('\\', "/");
|
||||
let (prefix, name) = split_tar_path(&normalized)?;
|
||||
let mut header = [0u8; 512];
|
||||
header[0..name.as_bytes().len()].copy_from_slice(name.as_bytes());
|
||||
let mode = tar_octal(mode, 8);
|
||||
header[100..108].copy_from_slice(&mode);
|
||||
header[108..116].copy_from_slice(&tar_octal(0, 8));
|
||||
header[116..124].copy_from_slice(&tar_octal(0, 8));
|
||||
header[124..136].copy_from_slice(&tar_octal(size, 12));
|
||||
header[136..148].copy_from_slice(&tar_octal(mtime, 12));
|
||||
header[148..156].fill(b' ');
|
||||
header[156] = typeflag;
|
||||
header[257..263].copy_from_slice(b"ustar\0");
|
||||
header[263..265].copy_from_slice(b"00");
|
||||
if !prefix.is_empty() {
|
||||
header[345..345 + prefix.as_bytes().len()].copy_from_slice(prefix.as_bytes());
|
||||
}
|
||||
let checksum = header.iter().map(|byte| u32::from(*byte)).sum::<u32>();
|
||||
let checksum_bytes = format!("{checksum:06o}\0 ").into_bytes();
|
||||
header[148..156].copy_from_slice(&checksum_bytes);
|
||||
out.extend_from_slice(&header);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_tar_padding(out: &mut Vec<u8>, size: usize) {
|
||||
let remainder = size % 512;
|
||||
if remainder > 0 {
|
||||
out.extend(std::iter::repeat(0).take(512 - remainder));
|
||||
}
|
||||
}
|
||||
|
||||
fn append_local_directory_tar_entries(
|
||||
out: &mut Vec<u8>,
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
base_name: &str,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let metadata = fs::symlink_metadata(current)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Ok(());
|
||||
}
|
||||
let relative = current.strip_prefix(root).unwrap_or(current);
|
||||
let mut entry_name = if relative.as_os_str().is_empty() {
|
||||
base_name.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{base_name}/{}",
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
)
|
||||
};
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
if metadata.is_dir() {
|
||||
if !entry_name.ends_with('/') {
|
||||
entry_name.push('/');
|
||||
}
|
||||
append_tar_header(out, &entry_name, 0, b'5', 0o755, mtime)?;
|
||||
let mut children = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
|
||||
children.sort_by_key(|entry| entry.file_name());
|
||||
for child in children {
|
||||
append_local_directory_tar_entries(out, root, &child.path(), base_name)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if metadata.is_file() {
|
||||
append_tar_header(out, &entry_name, metadata.len(), b'0', 0o644, mtime)?;
|
||||
let bytes = fs::read(current)?;
|
||||
out.extend_from_slice(&bytes);
|
||||
append_tar_padding(out, bytes.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_local_directory_tar_archive(directory: &Path) -> Result<Vec<u8>, std::io::Error> {
|
||||
let canonical = directory.canonicalize()?;
|
||||
let base_name = canonical
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("folder")
|
||||
.to_string();
|
||||
let mut out = Vec::new();
|
||||
append_local_directory_tar_entries(&mut out, &canonical, &canonical, &base_name)?;
|
||||
out.extend_from_slice(&[0u8; 1024]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn editor_block_table_to_markdown(block: &Value) -> String {
|
||||
let table = block
|
||||
.get("props")
|
||||
@@ -8873,6 +9206,90 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_download_sets_content_disposition_filename() {
|
||||
let root = temp_root("mnote-local-file-open-download-filename");
|
||||
init_workspace(&root);
|
||||
std::fs::write(root.join("报告 2026.docx"), b"docx").expect("write docx");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
let (_, response_headers, _) = open_local_file(
|
||||
Extension(context),
|
||||
Query(LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: "报告 2026.docx".into(),
|
||||
download: Some(true),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("download local file");
|
||||
|
||||
let disposition = response_headers
|
||||
.get(axum::http::header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("content disposition");
|
||||
assert!(disposition.starts_with("attachment;"));
|
||||
assert!(disposition.contains("filename=\"__ 2026.docx\""));
|
||||
assert!(
|
||||
disposition.contains("filename*=UTF-8''%E6%8A%A5%E5%91%8A%202026.docx"),
|
||||
"{disposition}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_open_downloads_directory_as_tar() {
|
||||
let root = temp_root("mnote-local-folder-open-download-directory");
|
||||
init_workspace(&root);
|
||||
let reports = root.join("Reports");
|
||||
std::fs::create_dir_all(&reports).expect("create reports");
|
||||
std::fs::write(reports.join("summary.txt"), b"summary").expect("write summary");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
let (_, response_headers, bytes) = open_local_file(
|
||||
Extension(context),
|
||||
Query(LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: "Reports".into(),
|
||||
download: Some(true),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("download local directory");
|
||||
|
||||
assert_eq!(
|
||||
response_headers
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/x-tar")
|
||||
);
|
||||
let disposition = response_headers
|
||||
.get(axum::http::header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("content disposition");
|
||||
assert!(disposition.contains("filename=\"Reports.tar\""));
|
||||
assert!(bytes.windows(5).any(|window| window == b"ustar"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_tree_classifies_mindmap_and_office_assets() {
|
||||
let root = temp_root("mnote-local-filetree-resource-kinds");
|
||||
|
||||
@@ -799,7 +799,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
||||
throw new Error('island runtime 导出不完整');
|
||||
}
|
||||
await runtime.default(wasmUrl);
|
||||
await runtime.default({ module_or_path: wasmUrl });
|
||||
if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') {
|
||||
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
|
||||
mount: runtime.mount_mindmap_shell,
|
||||
@@ -3260,6 +3260,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
};
|
||||
|
||||
const currentWebShellDocumentId = () => {
|
||||
const fromBody = document.body?.dataset?.documentId || '';
|
||||
if (fromBody) return String(fromBody).trim();
|
||||
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
|
||||
if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeResourceTabKind = (input) => {
|
||||
const kind = String(input?.kind || '').trim().toLowerCase();
|
||||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||
@@ -3365,7 +3373,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: '页面';
|
||||
const pageEntry = {
|
||||
objectIdentity: 'page',
|
||||
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId() || '').trim(),
|
||||
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || currentWebShellDocumentId() || '').trim(),
|
||||
workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
|
||||
title: pageTitle || '页面',
|
||||
kind: 'page',
|
||||
@@ -3772,7 +3780,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const openMindmapResourceTab = async (entry, input) => {
|
||||
const documentId = String(input.documentId || currentDocumentId() || '').trim();
|
||||
const documentId = String(input.documentId || currentWebShellDocumentId() || '').trim();
|
||||
const mindmapId = String(input.mindmapId || input.assetId || '').trim();
|
||||
if (!documentId || !mindmapId) throw new Error('mindmap_resource_identity_missing');
|
||||
const targetUrl = new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
|
||||
@@ -4645,6 +4653,10 @@ mod tests {
|
||||
assert!(html.contains("getOpenEditorsSnapshot"));
|
||||
assert!(html.contains("bindMainEditorTabStrip"));
|
||||
assert!(html.contains("data-mnote-tab-strip-bound"));
|
||||
assert!(html.contains("currentWebShellDocumentId"));
|
||||
assert!(!html
|
||||
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
|
||||
assert!(html.contains("runtime.default({ module_or_path: wasmUrl })"));
|
||||
assert!(html.contains("positionSlashMenuForRoot"));
|
||||
assert!(html.contains("menu.style.position = 'fixed';"));
|
||||
assert!(html.contains("installGlobalSlashMenuPositioning();"));
|
||||
|
||||
@@ -1764,6 +1764,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return String(item && item.title || '无标题').trim() || '无标题';
|
||||
}
|
||||
|
||||
function fileWorkspaceRelativePath(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {};
|
||||
var fromWorkspacePath = String(workspacePath.relativePath || '').trim();
|
||||
if (fromWorkspacePath) return fromWorkspacePath;
|
||||
var extra = meta.extra && typeof meta.extra === 'object' ? meta.extra : {};
|
||||
var source = extra.source && typeof extra.source === 'object' ? extra.source : {};
|
||||
var fromSource = String(source.relativePath || '').trim();
|
||||
if (fromSource) return fromSource;
|
||||
return String(item && (item.relativePath || item.rootRelativePath) || '').trim();
|
||||
}
|
||||
|
||||
function groupRowsByParent(rows) {
|
||||
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
|
||||
var grouped = new Map();
|
||||
@@ -1890,6 +1902,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var parent = parentIdOf(item);
|
||||
var documentId = fileDocumentId(item);
|
||||
var assetId = fileAssetId(item);
|
||||
var relativePath = fileWorkspaceRelativePath(item);
|
||||
var objectIdentity = fileObjectIdentity(item);
|
||||
var iconKind = iconKindOf(item);
|
||||
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
||||
@@ -1909,7 +1922,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-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) + '"><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-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>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -2581,6 +2594,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function fallbackFileTreeUploadTarget(detail) {
|
||||
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
|
||||
var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
|
||||
if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) {
|
||||
return {
|
||||
workspaceId: workspaceId,
|
||||
targetDocumentId: documentId,
|
||||
targetMindmapId: null,
|
||||
targetSubPath: null,
|
||||
targetRelativePath: String(detail.targetRelativePath || '')
|
||||
};
|
||||
}
|
||||
if (!workspaceId || !documentId) {
|
||||
throw new Error('请选择一个目标页面后再拖入文件');
|
||||
}
|
||||
@@ -2593,8 +2615,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
async function resolveFileTreeUploadTarget(detail) {
|
||||
if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) {
|
||||
return fallbackFileTreeUploadTarget(detail || {});
|
||||
}
|
||||
try {
|
||||
return await preflightFileTreeUploadTarget(detail || {});
|
||||
var plan = await preflightFileTreeUploadTarget(detail || {});
|
||||
if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) {
|
||||
plan.targetRelativePath = String(detail.targetRelativePath || '');
|
||||
}
|
||||
return plan;
|
||||
} catch (error) {
|
||||
console.warn('[mnote upload] upload target preflight fallback', error);
|
||||
return fallbackFileTreeUploadTarget(detail || {});
|
||||
@@ -2869,6 +2898,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var detail = detailFromEditorAttachmentLink(link);
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return;
|
||||
var localFilePath = localFilePathFromAssetId(assetId);
|
||||
if (localFilePath) {
|
||||
var localMeta = {
|
||||
assetId: assetId,
|
||||
fileSize: String(detail && detail.fileSize || '').trim()
|
||||
};
|
||||
attachmentMetaCache[assetId] = localMeta;
|
||||
applyEditorAttachmentMeta(link, localMeta);
|
||||
return;
|
||||
}
|
||||
if (attachmentMetaCache[assetId]) {
|
||||
applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]);
|
||||
return;
|
||||
@@ -3258,13 +3297,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
var rootUri = (new URLSearchParams(window.location.search).get('rootUri') || '').trim();
|
||||
var documentId = String(plan && plan.targetDocumentId || currentDocumentId() || '').trim();
|
||||
if (!rootUri || !documentId) {
|
||||
var hasFolderTarget = plan && Object.prototype.hasOwnProperty.call(plan, 'targetRelativePath');
|
||||
if (!rootUri || (!documentId && !hasFolderTarget)) {
|
||||
throw new Error('本地 Markdown 上传缺少 rootUri 或 documentId');
|
||||
}
|
||||
var localForm = new FormData();
|
||||
localForm.append('file', file);
|
||||
localForm.append('rootUri', rootUri);
|
||||
localForm.append('documentId', documentId);
|
||||
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', {
|
||||
method: 'POST',
|
||||
@@ -3546,6 +3587,28 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(url) {
|
||||
if (!url) return false;
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.download = '';
|
||||
link.style.position = 'fixed';
|
||||
link.style.left = '-9999px';
|
||||
link.style.top = '0';
|
||||
document.body.appendChild(link);
|
||||
try {
|
||||
link.click();
|
||||
} catch (_) {
|
||||
if (typeof window.open === 'function') window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
if (link.parentElement) link.parentElement.removeChild(link);
|
||||
}, 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordFileTreeAction(action, detail) {
|
||||
var normalized = String(action || '').trim() || 'unknown';
|
||||
var rowId = String(detail && detail.rowId || '').trim();
|
||||
@@ -3698,6 +3761,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
|
||||
var title = detail.title || '无标题';
|
||||
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
if (detail.contextKind === 'filetree' && action === 'download') {
|
||||
downloadSelectedFileTreeAssetRows(detail, trigger);
|
||||
return;
|
||||
}
|
||||
if (isAsset && action === 'new-window') {
|
||||
recordFileTreeAction('new-window', detail);
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
||||
@@ -3903,6 +3970,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
menu.setAttribute('data-kind', kind);
|
||||
var isAttachment = kind === 'attachment';
|
||||
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
var isFileTreeDownload = kind === 'filetree' && detail.downloadable;
|
||||
var items = isAttachment ? [
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
@@ -3926,6 +3994,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
] : isAsset ? [
|
||||
{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) > 1 ? '下载 ' + Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) + ' 个项目' : '下载' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
@@ -3935,6 +4004,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' }
|
||||
] : kind === 'filetree' ? [
|
||||
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
|
||||
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || 0) > 1 ? '下载 ' + Number(detail.selectedDownloadCount || 0) + ' 个项目' : '下载', disabled: !isFileTreeDownload, title: isFileTreeDownload ? '下载当前本地文件或文件夹' : '当前项目没有可下载的本地路径' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-link', icon: 'link', label: '复制访问链接' },
|
||||
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
|
||||
@@ -3987,19 +4057,31 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function openFileTreeContextMenu(row, x, y, trigger) {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
|
||||
var selectedDownloadRows = selectedSidebarFileTreeRowsForDownload(row);
|
||||
var selectedAssetRows = selectedDownloadRows.filter(isFileTreeDownloadableAssetRow);
|
||||
openTreeContextMenu('filetree', {
|
||||
documentId: documentId,
|
||||
rowId: row.getAttribute('data-row-id') || '',
|
||||
rowKind: row.getAttribute('data-row-kind') || '',
|
||||
assetId: row.getAttribute('data-asset-id') || '',
|
||||
localRelativePath: fileTreeRowLocalRelativePath(row),
|
||||
title: rowTitle(row),
|
||||
workspaceId: resolveWorkspaceId(row)
|
||||
workspaceId: resolveWorkspaceId(row),
|
||||
downloadable: isFileTreeDownloadableRow(row),
|
||||
selectedDownloadCount: selectedDownloadRows.length,
|
||||
selectedDownloadRowIds: selectedDownloadRows.map(function(downloadRow) { return downloadRow.getAttribute('data-row-id') || ''; }).filter(Boolean),
|
||||
selectedAssetCount: selectedAssetRows.length,
|
||||
selectedAssetRowIds: selectedAssetRows.map(function(assetRow) { return assetRow.getAttribute('data-row-id') || ''; }).filter(Boolean)
|
||||
}, x, y, trigger || row);
|
||||
}
|
||||
|
||||
function visibleFileTreeRows() {
|
||||
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
|
||||
.filter(function(row) { return row instanceof HTMLElement && row.offsetParent !== null; });
|
||||
.filter(function(row) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
if (row.closest('.tree-children--collapsed')) return false;
|
||||
return row.offsetParent !== null || row.getClientRects().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function syncSidebarFileTreeSelection() {
|
||||
@@ -4116,11 +4198,130 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '';
|
||||
}
|
||||
|
||||
function decodeLocalEncodedPath(value) {
|
||||
var path = String(value || '').trim().replace(/~2F/g, '/');
|
||||
if (!path) return '';
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
} catch (_) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeRowLocalRelativePath(row) {
|
||||
if (!(row instanceof HTMLElement)) return '';
|
||||
var direct = String(row.getAttribute('data-local-relative-path') || '').trim();
|
||||
if (direct) return direct;
|
||||
var assetPath = localFilePathFromAssetId(fileTreeRowAssetId(row));
|
||||
if (assetPath) return assetPath;
|
||||
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
|
||||
var documentId = fileTreeRowDocumentId(row);
|
||||
var kind = fileTreeRowKind(row);
|
||||
if (rowId.indexOf('local:asset:') === 0) return rowId.slice('local:asset:'.length);
|
||||
if (rowId.indexOf('local:markdown:') === 0) return rowId.slice('local:markdown:'.length);
|
||||
if (rowId.indexOf('local:folder:') === 0) return rowId.slice('local:folder:'.length);
|
||||
if (rowId.indexOf('local:node:') === 0) return rowId.slice('local:node:'.length);
|
||||
if (nodeId.indexOf('local:node:') === 0) return nodeId.slice('local:node:'.length);
|
||||
if ((kind === 'document' || kind === 'doc' || kind === 'markdown') && documentId.indexOf('local-md:') === 0) {
|
||||
return decodeLocalEncodedPath(documentId.slice('local-md:'.length));
|
||||
}
|
||||
if ((kind === 'folder' || kind === 'directory' || kind === 'index') && documentId.indexOf('local-dir:') === 0) {
|
||||
return decodeLocalEncodedPath(documentId.slice('local-dir:'.length));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function fileTreeRowKind(row) {
|
||||
if (!(row instanceof HTMLElement)) return '';
|
||||
return String(row.getAttribute('data-row-kind') || '').trim();
|
||||
}
|
||||
|
||||
function isFileTreeDownloadableAssetRow(row) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var kind = fileTreeRowKind(row);
|
||||
if (kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || kind === 'folder' || kind === 'directory') return false;
|
||||
return Boolean(fileTreeRowAssetId(row));
|
||||
}
|
||||
|
||||
function isFileTreeDownloadableRow(row) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
if (currentSourceKind() !== 'local_folder') return isFileTreeDownloadableAssetRow(row);
|
||||
return Boolean(fileTreeRowLocalRelativePath(row) || isFileTreeDownloadableAssetRow(row));
|
||||
}
|
||||
|
||||
function fileTreeAssetDownloadDetail(row) {
|
||||
if (!isFileTreeDownloadableRow(row)) return null;
|
||||
var title = rowTitle(row);
|
||||
var relativePath = fileTreeRowLocalRelativePath(row);
|
||||
var assetId = fileTreeRowAssetId(row) || (relativePath ? 'local-file:' + relativePath : '');
|
||||
return {
|
||||
contextKind: 'filetree',
|
||||
documentId: fileTreeRowDocumentId(row),
|
||||
rowId: row.getAttribute('data-row-id') || '',
|
||||
rowKind: fileTreeRowKind(row),
|
||||
assetId: assetId,
|
||||
localRelativePath: relativePath,
|
||||
title: title,
|
||||
fileName: title,
|
||||
workspaceId: resolveWorkspaceId(row)
|
||||
};
|
||||
}
|
||||
|
||||
function selectedSidebarFileTreeRowsForDownload(contextRow) {
|
||||
var contextRowId = contextRow instanceof HTMLElement ? contextRow.getAttribute('data-row-id') || '' : '';
|
||||
var selectedRows = selectedSidebarFileTreeRows().filter(isFileTreeDownloadableRow);
|
||||
if (contextRowId && sidebarFileTreeSelection.selectedRowIds.has(contextRowId) && selectedRows.length > 0) return selectedRows;
|
||||
return isFileTreeDownloadableRow(contextRow) ? [contextRow] : selectedRows;
|
||||
}
|
||||
|
||||
function selectedSidebarFileTreeAssetRowsForDownload(contextRow) {
|
||||
return selectedSidebarFileTreeRowsForDownload(contextRow).filter(isFileTreeDownloadableAssetRow);
|
||||
}
|
||||
|
||||
function downloadSelectedFileTreeAssetRows(detail, trigger) {
|
||||
var contextRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
|
||||
if (!(contextRow instanceof HTMLElement) && detail && detail.rowId) {
|
||||
contextRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.rowId) + '"]');
|
||||
}
|
||||
var rows = selectedSidebarFileTreeRowsForDownload(contextRow);
|
||||
var seen = new Set();
|
||||
var downloads = [];
|
||||
rows.forEach(function(row) {
|
||||
var item = fileTreeAssetDownloadDetail(row);
|
||||
var key = item && (item.localRelativePath || item.assetId || item.rowId);
|
||||
if (!item || !key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
downloads.push(item);
|
||||
});
|
||||
if (downloads.length === 0 && detail && detail.assetId) {
|
||||
downloads.push(Object.assign({}, detail, { fileName: detail.fileName || detail.title || '附件' }));
|
||||
} else if (downloads.length === 0 && detail && detail.localRelativePath) {
|
||||
downloads.push(Object.assign({}, detail, {
|
||||
assetId: detail.assetId || 'local-file:' + detail.localRelativePath,
|
||||
fileName: detail.fileName || detail.title || '附件'
|
||||
}));
|
||||
}
|
||||
if (downloads.length === 0) {
|
||||
recordFileTreeActionStatus('blocked', Object.assign({}, detail || {}, { reason: 'no-downloadable-assets' }));
|
||||
return false;
|
||||
}
|
||||
var primary = Object.assign({}, downloads[0], {
|
||||
count: downloads.length,
|
||||
assetIds: downloads.map(function(item) { return item.assetId || ''; }).filter(Boolean),
|
||||
localRelativePaths: downloads.map(function(item) { return item.localRelativePath || ''; }).filter(Boolean)
|
||||
});
|
||||
recordFileTreeAction(downloads.length > 1 ? 'bulk-download' : 'download', primary);
|
||||
recordFileTreeActionStatus('requested', primary);
|
||||
document.documentElement.setAttribute('data-mnote-filetree-download-count', String(downloads.length));
|
||||
document.documentElement.setAttribute('data-mnote-filetree-download-asset-ids', downloads.map(function(item) { return item.assetId || ''; }).filter(Boolean).join(','));
|
||||
document.documentElement.setAttribute('data-mnote-filetree-download-paths', downloads.map(function(item) { return item.localRelativePath || ''; }).filter(Boolean).join(','));
|
||||
downloads.forEach(function(item) {
|
||||
void openEditorAttachmentDownload(item);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasSelectedDocumentAncestor(row, selectedDocRowIds) {
|
||||
var node = row instanceof HTMLElement ? row.closest('.tree-node') : null;
|
||||
while (node && node.parentElement) {
|
||||
@@ -8010,7 +8211,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (localFilePath) {
|
||||
var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true);
|
||||
if (localDownloadUrl) {
|
||||
window.open(localDownloadUrl, '_blank', 'noopener,noreferrer');
|
||||
triggerBrowserDownload(localDownloadUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -8028,15 +8229,25 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
var localRelativePath = String(detail.localRelativePath || '').trim();
|
||||
if (localRelativePath) {
|
||||
var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true);
|
||||
if (localPathDownloadUrl) {
|
||||
triggerBrowserDownload(localPathDownloadUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
var target = detail.fileUrl || detail.href;
|
||||
if (!target) return;
|
||||
window.open(target, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function openEditorAttachmentMenu(link, trigger) {
|
||||
function openEditorAttachmentMenu(link, trigger, point) {
|
||||
var detail = detailFromEditorAttachmentLink(link);
|
||||
var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : link.getBoundingClientRect();
|
||||
openTreeContextMenu('attachment', detail, rect.right, rect.bottom + 4, trigger || link);
|
||||
var x = point && typeof point.x === 'number' ? point.x : rect.right;
|
||||
var y = point && typeof point.y === 'number' ? point.y : rect.bottom + 4;
|
||||
openTreeContextMenu('attachment', detail, x, y, trigger || link);
|
||||
}
|
||||
|
||||
function openEditorAttachmentLink(link) {
|
||||
@@ -8485,7 +8696,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
if (fileAction === 'menu') {
|
||||
e.preventDefault();
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
|
||||
if (rowId && !sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
|
||||
}
|
||||
var point = rowCenter(fileBtn || fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
openFileTreeContextMenu(fileRow, point.x, point.y, fileBtn || fileRow);
|
||||
@@ -8559,6 +8772,14 @@ 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');
|
||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
enhanceEditorAttachmentLink(editorAttachmentLink);
|
||||
openEditorAttachmentMenu(editorAttachmentLink, editorAttachmentLink, { x: event.clientX, y: event.clientY });
|
||||
return;
|
||||
}
|
||||
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
if (fileRow) {
|
||||
event.preventDefault();
|
||||
@@ -8904,7 +9125,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
||||
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
|
||||
documentId: targetRow ? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id') : null,
|
||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null
|
||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
|
||||
targetRelativePath: targetRow && (targetRow.getAttribute('data-row-kind') === 'folder' || targetRow.getAttribute('data-row-kind') === 'directory')
|
||||
? fileTreeRowLocalRelativePath(targetRow)
|
||||
: null
|
||||
};
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = null;
|
||||
@@ -9718,6 +9942,21 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function fileTreeMenuTargetParentId"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (action === 'new-folder')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("fileTreeCopyPath(detail, trigger)"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("if (detail.contextKind === 'filetree' && action === 'download')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("downloadSelectedFileTreeAssetRows(detail, trigger)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function selectedSidebarFileTreeRowsForDownload"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function selectedSidebarFileTreeAssetRowsForDownload"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-local-relative-path"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("targetRelativePath"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"recordFileTreeAction(downloads.length > 1 ? 'bulk-download' : 'download', primary)"
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-download-count"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function triggerBrowserDownload"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentMenu(editorAttachmentLink, editorAttachmentLink, { x: event.clientX, y: event.clientY });"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("var localFilePath = localFilePathFromAssetId(assetId);"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("attachmentMetaCache[assetId] = localMeta;"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"var pageRow = closestAction(e.target, '.tree-row[data-shell-mode=\"page\"]');"
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user