diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cfccded4..63d669e3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -174,6 +174,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -581,6 +582,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "entities" version = "1.0.1" @@ -1518,6 +1528,7 @@ dependencies = [ name = "mnote-web" version = "0.1.0" dependencies = [ + "adapter-onlyoffice", "axum", "base64", "bridge-runtime", @@ -1539,6 +1550,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "next_tuple" version = "0.1.0" @@ -2400,6 +2428,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 169d6fbd..257451f9 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -6,7 +6,8 @@ license.workspace = true authors.workspace = true [dependencies] -axum = { version = "0.8", features = ["ws"] } +adapter-onlyoffice = { path = "../adapter-onlyoffice" } +axum = { version = "0.8", features = ["multipart", "ws"] } bridge-runtime = { path = "../bridge-runtime" } core-protocol = { path = "../core-protocol" } futures-util = "0.3" diff --git a/rust/crates/mnote-web/src/routes/media.rs b/rust/crates/mnote-web/src/routes/media.rs new file mode 100644 index 00000000..d4d0a75e --- /dev/null +++ b/rust/crates/mnote-web/src/routes/media.rs @@ -0,0 +1,488 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name}; +use axum::extract::{Multipart, Query, State}; +use axum::http::{header, HeaderMap}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaSignQuery { + asset_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeUploadTargetPreflightPayload { + workspace_id: Option, + target_document_id: Option, + target_row_id: Option, + focused_row_id: Option, + active_document_id: Option, + rows: Option>, + document_workspaces: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct FileTreeUploadTargetRow { + row_id: Option, + row_kind: Option, + document_id: Option, + asset_id: Option, + asset_document_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeDocumentWorkspace { + document_id: Option, + workspace_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeUploadTargetPlan { + workspace_id: String, + target_document_id: String, + target_mindmap_id: Option, + target_sub_path: Option, +} + +#[derive(Debug)] +struct UploadFile { + name: String, + content_type: String, + bytes: Vec, +} + +async fn current_user_id(state: &AppState, context: &RequestContext) -> String { + let actor_id = context.auth.actor_id.trim(); + if !actor_id.is_empty() && actor_id != "anonymous" { + return actor_id.to_string(); + } + if let Ok(user) = execute_convex_query_by_name( + state.config(), + context, + "users:currentUser", + json!({}), + context.workspace.workspace_id.as_deref(), + "media_current_user", + ) + .await + { + for key in ["_id", "id"] { + if let Some(user_id) = user + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return user_id.to_string(); + } + } + } + state.config().dev_user_id.clone() +} + +fn now_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn new_asset_id() -> String { + format!( + "asset_{}_{}", + now_millis(), + UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed) + ) +} + +fn asset_type(mime: &str) -> &'static str { + if mime.starts_with("image/") { + "image" + } else if mime.starts_with("video/") { + "video" + } else if mime.starts_with("audio/") { + "audio" + } else { + "file" + } +} + +async fn read_upload_multipart( + mut multipart: Multipart, +) -> Result<(UploadFile, String, String, Option), WebError> { + let mut file: Option = None; + let mut workspace_id = String::new(); + let mut document_id = String::new(); + let mut mindmap_id: Option = None; + + while let Some(field) = multipart.next_field().await.map_err(|error| { + WebError::bad_request_code( + "media_upload_bad_multipart", + format!("上传表单解析失败: {error}"), + ) + })? { + let name = field.name().unwrap_or_default().to_string(); + if name == "file" { + let file_name = field + .file_name() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("附件") + .to_string(); + let content_type = field + .content_type() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); + let bytes = field + .bytes() + .await + .map_err(|error| { + WebError::bad_request_code( + "media_upload_file_read_failed", + format!("读取上传文件失败: {error}"), + ) + })? + .to_vec(); + file = Some(UploadFile { + name: file_name, + content_type, + bytes, + }); + continue; + } + + let value = field.text().await.map_err(|error| { + WebError::bad_request_code( + "media_upload_field_read_failed", + format!("读取上传字段失败: {error}"), + ) + })?; + match name.as_str() { + "workspaceId" => workspace_id = value.trim().to_string(), + "documentId" => document_id = value.trim().to_string(), + "mindmapId" => { + let trimmed = value.trim(); + if !trimmed.is_empty() { + mindmap_id = Some(trimmed.to_string()); + } + } + _ => {} + } + } + + let file = + file.ok_or_else(|| WebError::bad_request_code("media_upload_file_missing", "缺少 file"))?; + if file.bytes.is_empty() || workspace_id.is_empty() || document_id.is_empty() { + return Err(WebError::bad_request_code( + "media_upload_required_missing", + "缺少必要参数", + )); + } + Ok((file, workspace_id, document_id, mindmap_id)) +} + +fn absolute_origin(headers: &HeaderMap) -> String { + let proto = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("http"); + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("127.0.0.1:3000"); + format!("{proto}://{host}") +} + +fn proxied_file_url(headers: &HeaderMap, raw: &str) -> String { + let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes()); + format!( + "{}/api/onlyoffice/proxy?u={encoded}", + absolute_origin(headers) + ) +} + +fn trim_string(value: Option<&String>) -> Option { + value + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn workspace_for_document( + document_id: &str, + fallback_workspace_id: Option<&str>, + document_workspaces: &[FileTreeDocumentWorkspace], +) -> Option { + for item in document_workspaces { + if trim_string(item.document_id.as_ref()).as_deref() == Some(document_id) { + if let Some(workspace_id) = trim_string(item.workspace_id.as_ref()) { + return Some(workspace_id); + } + } + } + fallback_workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn document_for_target_row( + target_row_id: Option<&str>, + rows: &[FileTreeUploadTargetRow], +) -> Option { + let row_id = target_row_id?.trim(); + if row_id.is_empty() { + return None; + } + for row in rows { + if trim_string(row.row_id.as_ref()).as_deref() != Some(row_id) { + continue; + } + if let Some(document_id) = trim_string(row.document_id.as_ref()) { + return Some(document_id); + } + if let Some(document_id) = trim_string(row.asset_document_id.as_ref()) { + return Some(document_id); + } + } + None +} + +pub async fn upload( + State(state): State, + Extension(context): Extension, + multipart: Multipart, +) -> Result { + let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?; + let user_id = current_user_id(&state, &context).await; + let upload_url = execute_convex_mutation_by_name( + state.config(), + &context, + "mediaAssets:generateUploadUrl", + json!({ "userId": user_id }), + Some(&workspace_id), + None, + "media_upload_generate_url", + ) + .await?; + let upload_url = upload_url.as_str().ok_or_else(|| { + WebError::bad_gateway_code("media_upload_bad_upload_url", "Convex 未返回上传 URL") + })?; + + let client = reqwest::Client::new(); + let upload_response = client + .post(upload_url) + .header(header::CONTENT_TYPE, file.content_type.as_str()) + .body(file.bytes.clone()) + .send() + .await + .map_err(|error| { + WebError::bad_gateway_code( + "media_upload_storage_failed", + format!("上传到 Convex Files 失败: {error}"), + ) + })?; + let upload_status = upload_response.status(); + let upload_json: Value = upload_response.json().await.map_err(|error| { + WebError::bad_gateway_code( + "media_upload_storage_bad_response", + format!("Convex Files 响应解析失败: {error}"), + ) + .with_header("x-upstream-status", upload_status.as_u16().to_string()) + })?; + if !upload_status.is_success() { + return Err(WebError::bad_gateway_code( + "media_upload_storage_status", + format!("上传到 Convex Files 失败: {upload_json}"), + ) + .with_header("x-upstream-status", upload_status.as_u16().to_string())); + } + let storage_id = upload_json + .get("storageId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_gateway_code( + "media_upload_storage_id_missing", + "Convex Files 缺少 storageId", + ) + })?; + + let target_sub_path = mindmap_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("mindmaps/{value}")); + let id = new_asset_id(); + let kind = asset_type(&file.content_type); + let created = execute_convex_mutation_by_name( + state.config(), + &context, + "mediaAssets:createWithStorage", + json!({ + "userId": user_id, + "storageId": storage_id, + "targetSubPath": target_sub_path, + "asset": { + "id": id, + "workspace_id": workspace_id, + "document_id": document_id, + "asset_type": kind, + "file_name": file.name, + "file_size": file.bytes.len(), + "mime_type": file.content_type, + } + }), + Some(&workspace_id), + None, + "media_upload_create_asset", + ) + .await?; + + let asset_id = created + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + Ok(Json(json!({ + "asset": created, + "mindmapUrl": format!("asset:{asset_id}"), + })) + .into_response()) +} + +pub async fn filetree_upload_target_preflight( + Extension(context): Extension, + Json(payload): Json, +) -> Result { + let rows = payload.rows.unwrap_or_default(); + let document_workspaces = payload.document_workspaces.unwrap_or_default(); + let fallback_workspace_id = trim_string(payload.workspace_id.as_ref()); + let target_row_document_id = document_for_target_row( + trim_string(payload.target_row_id.as_ref()).as_deref(), + &rows, + ); + let document_id = trim_string(payload.target_document_id.as_ref()) + .or(target_row_document_id) + .or_else(|| { + trim_string(payload.focused_row_id.as_ref()) + .and_then(|row_id| document_for_target_row(Some(&row_id), &rows)) + }) + .or_else(|| trim_string(payload.active_document_id.as_ref())) + .ok_or_else(|| { + WebError::bad_request_code( + "filetree_upload_target_document_missing", + "请选择一个目标页面后再上传文件", + ) + })?; + let workspace_id = workspace_for_document( + &document_id, + fallback_workspace_id + .as_deref() + .or(context.workspace.workspace_id.as_deref()), + &document_workspaces, + ) + .ok_or_else(|| { + WebError::bad_request_code( + "filetree_upload_target_workspace_missing", + "缺少 workspaceId", + ) + })?; + let plan = FileTreeUploadTargetPlan { + workspace_id, + target_document_id: document_id, + target_mindmap_id: None, + target_sub_path: None, + }; + Ok(Json(json!({ + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "plan": plan, + })) + .into_response()) +} + +pub async fn sign( + State(state): State, + Extension(context): Extension, + Query(query): Query, + headers: HeaderMap, +) -> Result { + let asset_id = query + .asset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?; + let user_id = current_user_id(&state, &context).await; + let asset = execute_convex_query_by_name( + state.config(), + &context, + "mediaAssets:getById", + json!({ "userId": user_id, "id": asset_id }), + None, + "media_sign_get_asset", + ) + .await?; + if asset.is_null() { + return Err(WebError::new( + axum::http::StatusCode::NOT_FOUND, + "media_asset_not_found", + "资源不存在", + )); + } + let refreshed = execute_convex_mutation_by_name( + state.config(), + &context, + "mediaAssets:refreshUrl", + json!({ "userId": user_id, "id": asset_id }), + None, + None, + "media_sign_refresh_url", + ) + .await?; + let signed_url = refreshed + .get("signedUrl") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WebError::bad_gateway_code("media_sign_url_missing", "生成签名链接失败"))?; + + Ok(Json(json!({ + "signedUrl": proxied_file_url(&headers, signed_url), + "asset": { + "id": asset.get("id").cloned().unwrap_or(Value::Null), + "document_id": asset.get("document_id").cloned().unwrap_or(Value::Null), + "workspace_id": asset.get("workspace_id").cloned().unwrap_or(Value::Null), + "file_name": asset.get("file_name").cloned().unwrap_or(Value::Null), + "mime_type": asset.get("mime_type").cloned().unwrap_or(Value::Null), + "file_size": asset.get("file_size").cloned().unwrap_or(Value::Null), + "storage_id": asset.get("storage_id").cloned().unwrap_or(Value::Null), + "updated_at": asset.get("updated_at").cloned().unwrap_or(Value::Null), + } + })) + .into_response()) +} diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 067e91b5..07b37215 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -10,7 +10,9 @@ mod kernel; mod local_folder_source; mod local_folder_events; mod local_markdown_parser; +mod media; mod mindmap_shell; +mod onlyoffice; mod query_support; mod search; mod session; @@ -22,7 +24,7 @@ mod web_shell; mod ws; use crate::app::AppState; -use axum::routing::{get, post}; +use axum::routing::{any, get, post}; use axum::Router; pub fn build_router(state: AppState) -> Router { @@ -69,6 +71,19 @@ pub fn build_router(state: AppState) -> Router { .route("/api/auth/mnote-web-token", get(session::session)) .route("/api/auth/session/refresh", post(session::refresh_session)) .route("/api/ai-agent/run", post(compat::next_ai_agent_run)) + .route("/onlyoffice", get(onlyoffice::page)) + .route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy)) + .route("/cache/{*path}", any(onlyoffice::cache_proxy)) + .route("/api/onlyoffice/sign", post(onlyoffice::sign)) + .route("/api/onlyoffice/proxy", get(onlyoffice::proxy)) + .route("/api/onlyoffice/callback", post(onlyoffice::callback)) + .route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave)) + .route("/api/media/upload", post(media::upload)) + .route("/api/media/sign", get(media::sign)) + .route( + "/api/tree/filetree/upload-target-preflight", + post(media::filetree_upload_target_preflight), + ) .route("/api/documents/meta", get(documents::meta)) .route("/api/documents/content", get(documents::content)) .route("/api/documents/page", get(web_shell::documents_page_compat)) diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs new file mode 100644 index 00000000..d3673efc --- /dev/null +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -0,0 +1,1006 @@ +use crate::app::AppState; +use crate::error::WebError; +use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput}; +use axum::body::{Body, Bytes}; +use axum::extract::{Path, Query, State}; +use axum::http::{header, HeaderMap, Method, Request, Uri}; +use axum::response::{Html, IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::env; +use std::fs; +use std::time::Duration; + +const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js"; +const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082"; +const ONLYOFFICE_RUNTIME_REWRITE_SNIPPET: &str = r#""#; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OnlyOfficePageQuery { + file_url: Option, + file_name: Option, + file_type: Option, + asset_id: Option, + document_id: Option, + user_id: Option, + mode: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OnlyOfficeProxyQuery { + u: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OnlyOfficeCallbackQuery { + #[serde(rename = "assetId")] + asset_id: Option, + #[serde(rename = "userId")] + user_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OnlyOfficeForcesaveQuery { + #[serde(rename = "assetId")] + asset_id: Option, + key: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OnlyOfficeSignPayload { + config: Option, +} + +fn env_or_dotenv(key: &str) -> Option { + if let Ok(value) = env::var(key) { + let trimmed = value.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join(".env.all"); + let content = fs::read_to_string(root).ok()?; + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + if k.trim() != key { + continue; + } + let trimmed = v.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + None +} + +fn normalize_http_origin(raw: &str) -> Option { + let value = raw.trim().trim_end_matches('/'); + if value.is_empty() || value == "/onlyoffice-server" { + return None; + } + let url = reqwest::Url::parse(value).ok()?; + if url.scheme() != "http" && url.scheme() != "https" { + return None; + } + Some(url.to_string().trim_end_matches('/').to_string()) +} + +fn onlyoffice_internal_candidates() -> Vec { + let mut candidates = Vec::new(); + let mut push = |value: Option| { + let Some(value) = value else { + return; + }; + let Some(normalized) = normalize_http_origin(&value) else { + return; + }; + if !candidates.contains(&normalized) { + candidates.push(normalized); + } + }; + + push(env_or_dotenv("ONLYOFFICE_INTERNAL_URL")); + if let Some(raw) = env_or_dotenv("ONLYOFFICE_INTERNAL_URL_CANDIDATES") { + for value in raw.split(',') { + push(Some(value.to_string())); + } + } + push(Some(DEFAULT_ONLYOFFICE_INTERNAL_URL.into())); + push(Some("http://127.0.0.1:8081".into())); + push(Some("http://localhost:8082".into())); + push(Some("http://localhost:8081".into())); + if candidates.is_empty() { + candidates.push(DEFAULT_ONLYOFFICE_INTERNAL_URL.into()); + } + candidates +} + +async fn resolve_onlyoffice_internal_url() -> String { + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(2_500)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let candidates = onlyoffice_internal_candidates(); + for candidate in &candidates { + let probe_url = format!("{candidate}{ONLYOFFICE_PROBE_PATH}"); + if client + .head(probe_url) + .send() + .await + .map(|response| response.status().is_success()) + .unwrap_or(false) + { + return candidate.clone(); + } + } + candidates + .first() + .cloned() + .unwrap_or_else(|| DEFAULT_ONLYOFFICE_INTERNAL_URL.into()) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn json_string(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()) +} + +fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn forwarded_public_origin(headers: &HeaderMap) -> (String, String, String) { + let origin_like = header_value(headers, "origin") + .or_else(|| header_value(headers, "referer")) + .and_then(|value| reqwest::Url::parse(&value).ok()); + let forwarded_host_raw = header_value(headers, "x-forwarded-host") + .or_else(|| header_value(headers, header::HOST.as_str())) + .or_else(|| { + origin_like.as_ref().map(|url| match url.port() { + Some(port) => format!("{}:{port}", url.host_str().unwrap_or_default()), + None => url.host_str().unwrap_or_default().to_string(), + }) + }) + .unwrap_or_else(|| "127.0.0.1:3000".into()); + let forwarded_host = forwarded_host_raw + .split(',') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("127.0.0.1:3000") + .to_string(); + let forwarded_proto = header_value(headers, "x-forwarded-proto") + .and_then(|value| { + value + .split(',') + .next() + .map(str::trim) + .map(ToOwned::to_owned) + }) + .filter(|value| !value.is_empty()) + .or_else(|| origin_like.as_ref().map(|url| url.scheme().to_string())) + .unwrap_or_else(|| "http".into()); + let forwarded_port = header_value(headers, "x-forwarded-port") + .and_then(|value| { + value + .split(',') + .next() + .map(str::trim) + .map(ToOwned::to_owned) + }) + .filter(|value| !value.is_empty()) + .or_else(|| { + forwarded_host + .rsplit(':') + .next() + .filter(|value| value.chars().all(|ch| ch.is_ascii_digit())) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| { + if forwarded_proto == "https" { + "443".into() + } else { + "80".into() + } + }); + (forwarded_host, forwarded_proto, forwarded_port) +} + +pub async fn page(Query(query): Query) -> Result { + let file_url = query.file_url.unwrap_or_default(); + let file_name = query.file_name.unwrap_or_else(|| "附件".into()); + let file_type = query.file_type.unwrap_or_else(|| "docx".into()); + let asset_id = query.asset_id.unwrap_or_default(); + let document_id = query.document_id.unwrap_or_default(); + let user_id = query.user_id.unwrap_or_default(); + let mode = match query.mode.as_deref() { + Some("view") => "view", + _ => "edit", + }; + + let html = format!( + r#" + + + + + {title} + + + +
+

ONLYOFFICE 加载失败

+ + +"#, + title = escape_html(&file_name), + file_url = json_string(&file_url), + file_name = json_string(&file_name), + file_type = json_string(&file_type), + asset_id = json_string(&asset_id), + document_id = json_string(&document_id), + user_id = json_string(&user_id), + mode = json_string(mode), + ); + + Ok(Html(html).into_response()) +} + +pub async fn sign(Json(payload): Json) -> Result { + let config = payload.config.ok_or_else(|| { + WebError::bad_request_code("onlyoffice_sign_config_missing", "缺少 config") + })?; + let secret = env_or_dotenv("ONLYOFFICE_JWT_SECRET").unwrap_or_default(); + let tokens = sign_config(&config, &secret) + .map_err(|error| WebError::internal(format!("OnlyOffice 签名失败: {error}")))?; + Ok(Json(tokens).into_response()) +} + +fn proxy_origin_env(key: &str) -> Option { + env_or_dotenv(key).and_then(|value| normalize_http_origin(&value)) +} + +pub async fn proxy( + Query(query): Query, + headers: HeaderMap, + method: Method, +) -> Result { + let encoded_url = query + .u + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WebError::bad_request_code("onlyoffice_proxy_url_missing", "缺少 u"))?; + let prepared = prepare_proxy_request(OnlyOfficeProxyPreparationInput { + encoded_url: encoded_url.to_string(), + method: method.as_str().to_string(), + range: headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned), + supabase_url: proxy_origin_env("NEXT_PUBLIC_SUPABASE_URL") + .or_else(|| proxy_origin_env("SUPABASE_URL")), + supabase_internal_url: proxy_origin_env("SUPABASE_INTERNAL_URL"), + onlyoffice_storage_host_override: env_or_dotenv( + "NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE", + ), + convex_origin: proxy_origin_env("CONVEX_SELF_HOSTED_URL") + .or_else(|| proxy_origin_env("NEXT_PUBLIC_CONVEX_URL")), + supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY") + .or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")), + }) + .map_err(|error| WebError::bad_request_code("onlyoffice_proxy_prepare_failed", error))?; + + let client = reqwest::Client::new(); + let mut request = client.request( + reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET), + &prepared.target_url, + ); + for item in prepared.forward_headers { + request = request.header(item.name, item.value); + } + let upstream = request.send().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_proxy_fetch_failed", + format!("回源下载失败: {error}"), + ) + })?; + let status = upstream.status(); + let mut response_headers = HeaderMap::new(); + for (name, value) in upstream.headers() { + if name == header::SET_COOKIE { + continue; + } + response_headers.insert(name, value.clone()); + } + if method == Method::HEAD { + let mut response = Response::new(Body::empty()); + *response.status_mut() = status; + *response.headers_mut() = response_headers; + return Ok(response); + } + let bytes = upstream.bytes().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_proxy_body_failed", + format!("读取回源文件失败: {error}"), + ) + })?; + let mut response = Response::new(Body::from(bytes)); + *response.status_mut() = status; + *response.headers_mut() = response_headers; + Ok(response) +} + +pub async fn callback( + Query(query): Query, + Json(body): Json, +) -> Response { + let status = body + .get("status") + .and_then(Value::as_i64) + .unwrap_or_default(); + tracing::info!( + asset_id = query.asset_id.as_deref().unwrap_or(""), + user_id = query.user_id.as_deref().unwrap_or(""), + status, + "OnlyOffice callback received by mnote-web" + ); + Json(json!({ "error": 0 })).into_response() +} + +pub async fn forcesave( + Query(query): Query, +) -> Result { + let asset_id = query + .asset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("onlyoffice_forcesave_asset_missing", "缺少 assetId") + })?; + let key = query + .key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key") + })?; + Ok(Json(json!({ + "ok": true, + "via": "mnote-web-rust-noop", + "assetId": asset_id, + "key": key, + })) + .into_response()) +} + +fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String { + let mut target = format!( + "{}/{}", + base.trim_end_matches('/'), + path.trim_start_matches('/') + ); + if let Some(query) = query.filter(|value| !value.is_empty()) { + target.push('?'); + target.push_str(query); + } + target +} + +fn request_body_bytes( + request: Request, +) -> impl std::future::Future> { + async move { + axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024) + .await + .map_err(|error| { + WebError::bad_request_code( + "onlyoffice_server_body_failed", + format!("读取请求体失败: {error}"), + ) + }) + } +} + +fn inject_onlyoffice_html_fixups(body: &str) -> String { + if body.contains("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__") { + return body.to_string(); + } + body.replacen( + "", + &format!("\n{}\n", ONLYOFFICE_RUNTIME_REWRITE_SNIPPET), + 1, + ) +} + +fn strip_hop_by_hop_headers(headers: &mut HeaderMap) { + for name in [ + header::CONNECTION, + header::CONTENT_ENCODING, + header::CONTENT_LENGTH, + header::HeaderName::from_static("keep-alive"), + header::HeaderName::from_static("proxy-authenticate"), + header::HeaderName::from_static("proxy-authorization"), + header::TE, + header::TRAILER, + header::TRANSFER_ENCODING, + header::UPGRADE, + ] { + headers.remove(name); + } +} + +async fn proxy_onlyoffice_path( + upstream_prefix: &str, + upstream_path: &str, + uri: Uri, + method: Method, + headers: HeaderMap, + request: Request, +) -> Result { + let base = resolve_onlyoffice_internal_url().await; + let prefix = upstream_prefix.trim_matches('/'); + let normalized_path = upstream_path.trim_start_matches('/'); + let is_cache_path = prefix == "cache" || normalized_path.starts_with("cache/"); + let target_base = if prefix.is_empty() { + base.clone() + } else { + format!("{}/{}", base.trim_end_matches('/'), prefix) + }; + let target = append_path_and_query(&target_base, upstream_path, uri.query()); + let target_origin = reqwest::Url::parse(&base).ok(); + let body = request_body_bytes(request).await?; + let client = reqwest::Client::new(); + let mut builder = client.request( + reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET), + target, + ); + for (name, value) in headers.iter() { + if matches!( + name.as_str(), + "host" + | "connection" + | "upgrade" + | "sec-websocket-key" + | "sec-websocket-version" + | "accept-encoding" + ) { + continue; + } + builder = builder.header(name, value); + } + // 说明:部分 ONLYOFFICE HTML 需要注入同源代理补丁。这里强制回源明文, + // 避免 gzip 字节被当成 text/html 注入后在 iframe 中显示乱码。 + builder = builder.header(header::ACCEPT_ENCODING, "identity"); + if let Some(base_url) = target_origin.as_ref() { + builder = builder.header(header::HOST, base_url.host_str().unwrap_or("127.0.0.1")); + } + let (forwarded_host, forwarded_proto, forwarded_port) = forwarded_public_origin(&headers); + builder = builder + .header("x-forwarded-host", forwarded_host) + .header("x-forwarded-proto", forwarded_proto) + .header("x-forwarded-port", forwarded_port) + .header("x-forwarded-prefix", "/onlyoffice-server"); + if method != Method::GET && method != Method::HEAD { + builder = builder.body(body); + } + let upstream = builder.send().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_server_proxy_failed", + format!("OnlyOffice 代理失败: {error}"), + ) + })?; + let status = upstream.status(); + let upstream_headers = upstream.headers().clone(); + let mut response_headers = HeaderMap::new(); + for (name, value) in upstream_headers.iter() { + if name == header::SET_COOKIE { + continue; + } + response_headers.insert(name, value.clone()); + } + if method == Method::HEAD { + if is_cache_path { + let upstream_content_length = upstream_headers.get(header::CONTENT_LENGTH).cloned(); + strip_hop_by_hop_headers(&mut response_headers); + if let Some(content_length) = upstream_content_length { + response_headers.insert(header::CONTENT_LENGTH, content_length); + } + } + let mut response = Response::new(Body::empty()); + *response.status_mut() = status; + *response.headers_mut() = response_headers; + return Ok(response); + } + let bytes = upstream.bytes().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_server_proxy_body_failed", + format!("读取 OnlyOffice 响应失败: {error}"), + ) + })?; + let maybe_html = upstream_headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_ascii_lowercase) + .map(|value| value.contains("text/html")) + .unwrap_or(false); + let mut response = if maybe_html { + let html = String::from_utf8_lossy(&bytes); + let body = inject_onlyoffice_html_fixups(&html); + strip_hop_by_hop_headers(&mut response_headers); + response_headers.insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-store"), + ); + response_headers.insert( + header::CONTENT_LENGTH, + header::HeaderValue::from_str(&body.as_bytes().len().to_string()) + .unwrap_or_else(|_| header::HeaderValue::from_static("0")), + ); + Response::new(Body::from(body)) + } else { + if is_cache_path { + strip_hop_by_hop_headers(&mut response_headers); + response_headers.insert( + header::CONTENT_LENGTH, + header::HeaderValue::from_str(&bytes.len().to_string()) + .unwrap_or_else(|_| header::HeaderValue::from_static("0")), + ); + } + Response::new(Body::from(bytes)) + }; + *response.status_mut() = status; + *response.headers_mut() = response_headers; + Ok(response) +} + +pub async fn server_proxy( + State(_state): State, + Path(path): Path, + uri: Uri, + method: Method, + headers: HeaderMap, + request: Request, +) -> Result { + proxy_onlyoffice_path("", &path, uri, method, headers, request).await +} + +pub async fn cache_proxy( + State(_state): State, + Path(path): Path, + uri: Uri, + method: Method, + headers: HeaderMap, + request: Request, +) -> Result { + proxy_onlyoffice_path("cache", &path, uri, method, headers, request).await +} + +#[cfg(test)] +pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String { + if !asset_id.trim().is_empty() { + if storage_id.trim().is_empty() { + return asset_id.trim().to_string(); + } + return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id)); + } + js_hash_abs(&format!("{file_url}-{file_name}")) +} + +#[cfg(test)] +fn js_hash_abs(input: &str) -> String { + let mut hash: i32 = 0; + for unit in input.encode_utf16() { + hash = hash + .wrapping_shl(5) + .wrapping_sub(hash) + .wrapping_add(unit as i32); + } + hash.abs().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_doc_key_uses_onlyoffice_safe_characters() { + let key = stable_doc_key("asset_1", "kg2abc:def", "", ""); + assert!(key.len() <= 128); + assert!(key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); + assert!(key.starts_with("asset_1_")); + } + + #[test] + fn onlyoffice_internal_candidates_keep_default_first_after_env() { + let candidates = onlyoffice_internal_candidates(); + assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string())); + } +} diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 9a6bf8c5..146bc321 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -25,6 +25,8 @@ const SIDEBAR_TREE_JS: &str = r##" }; var projectionRefreshTimer = 0; var activeTreeContextMenu = null; + var activeEditorAttachmentLink = null; + var attachmentActionsHideTimer = 0; var pageUiState = { pageOptions: null, historySnapshots: [], @@ -1024,6 +1026,608 @@ const SIDEBAR_TREE_JS: &str = r##" window.dispatchEvent(new CustomEvent(name, { detail: detail })); } + function inferOnlyOfficeFileType(fileName, mimeType) { + var name = String(fileName || '').trim().toLowerCase(); + var mt = String(mimeType || '').trim().toLowerCase(); + var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; + if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return ext; + if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return ext; + if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return ext; + if (ext === 'pdf') return ext; + if (mt.indexOf('wordprocessingml') >= 0) return 'docx'; + if (mt.indexOf('presentationml') >= 0) return 'pptx'; + if (mt.indexOf('spreadsheetml') >= 0) return 'xlsx'; + if (mt.indexOf('pdf') >= 0) return 'pdf'; + return ''; + } + + function buildOnlyOfficeOpenUrl(input) { + var target = new URL('/onlyoffice', window.location.origin); + target.searchParams.set('fileUrl', input.fileUrl || ''); + target.searchParams.set('fileName', input.fileName || '未命名资源'); + target.searchParams.set('fileType', input.fileType || 'docx'); + if (input.assetId) target.searchParams.set('assetId', input.assetId); + if (input.documentId) target.searchParams.set('documentId', input.documentId); + if (input.userId) target.searchParams.set('userId', input.userId); + target.searchParams.set('mode', input.mode || 'edit'); + return target.toString(); + } + + function buildOnlyOfficeOpenPath(input) { + var params = new URLSearchParams(); + params.set('fileUrl', input.fileUrl || ''); + params.set('fileName', input.fileName || '未命名资源'); + params.set('fileType', input.fileType || 'docx'); + if (input.assetId) params.set('assetId', input.assetId); + if (input.documentId) params.set('documentId', input.documentId); + if (input.userId) params.set('userId', input.userId); + params.set('mode', input.mode || 'edit'); + return '/onlyoffice?' + params.toString(); + } + + async function fetchCurrentOnlyOfficeUserId() { + try { + var response = await fetch('/api/auth/whoami', { + method: 'GET', + credentials: 'include', + cache: 'no-store' + }); + var payload = await response.json().catch(function() { return null; }); + return String(payload && payload.userId || '').trim(); + } catch (_error) { + return ''; + } + } + + async function openConvexAssetFromFileTree(detail) { + var assetId = String(detail && detail.assetId || '').trim(); + if (!assetId) return; + try { + var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), { + method: 'GET', + credentials: 'include' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) { + throw new Error(payload && payload.error ? payload.error : '生成签名链接失败'); + } + var asset = payload && payload.asset && typeof payload.asset === 'object' ? payload.asset : {}; + var fileUrl = String(payload && payload.signedUrl || asset.file_url || asset.signed_url || '').trim(); + if (!fileUrl) throw new Error('附件链接不可用'); + var fileName = String(asset.file_name || detail.fileName || '未命名资源').trim() || '未命名资源'; + var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type); + if (fileType) { + var userId = await fetchCurrentOnlyOfficeUserId(); + window.open(buildOnlyOfficeOpenUrl({ + fileUrl: fileUrl, + fileName: fileName, + fileType: fileType, + assetId: assetId, + documentId: String(asset.document_id || detail.documentId || '').trim(), + userId: userId, + mode: 'edit' + }), '_blank', 'noopener,noreferrer'); + return; + } + window.open(fileUrl, '_blank', 'noopener,noreferrer'); + } catch (error) { + window.alert(error && error.message ? error.message : '打开附件失败'); + } + } + + window.addEventListener('tree.asset.open', function(event) { + void openConvexAssetFromFileTree(event.detail || {}); + }); + + function fileTreeRowsForUploadPreflight() { + return Array.from(document.querySelectorAll('.tree-row[data-shell-mode="filetree"]')).map(function(row) { + return { + rowId: row.getAttribute('data-row-id') || '', + rowKind: row.getAttribute('data-row-kind') || '', + documentId: row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || null, + assetId: row.getAttribute('data-asset-id') || null, + assetDocumentId: row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || null, + assetType: row.querySelector('.tree-kind-badge') ? row.querySelector('.tree-kind-badge').getAttribute('data-kind') : null, + storagePath: null + }; + }).filter(function(row) { + return row.rowId || row.documentId || row.assetId; + }); + } + + function fileTreeDocumentWorkspacesForUploadPreflight(workspaceId) { + var seen = new Set(); + return fileTreeRowsForUploadPreflight().filter(function(row) { + if (!row.documentId || seen.has(row.documentId)) return false; + seen.add(row.documentId); + return true; + }).map(function(row) { + return { documentId: row.documentId, workspaceId: workspaceId || null }; + }); + } + + async function preflightFileTreeUploadTarget(detail) { + var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(); + var body = { + workspaceId: workspaceId || null, + targetDocumentId: detail && detail.documentId ? String(detail.documentId) : null, + targetRowId: detail && detail.targetRowId ? String(detail.targetRowId) : null, + focusedRowId: sidebarFileTreeSelection.focusedRowId || null, + activeDocumentId: currentDocumentId() || null, + rows: fileTreeRowsForUploadPreflight(), + documentWorkspaces: fileTreeDocumentWorkspacesForUploadPreflight(workspaceId) + }; + var response = await fetch('/api/tree/filetree/upload-target-preflight', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok || !payload || !payload.plan) { + throw new Error(payload && payload.error ? payload.error : '文件树上传目标预检失败'); + } + return payload.plan; + } + + function fallbackFileTreeUploadTarget(detail) { + var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(); + var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim(); + if (!workspaceId || !documentId) { + throw new Error('请选择一个目标页面后再拖入文件'); + } + return { + workspaceId: workspaceId, + targetDocumentId: documentId, + targetMindmapId: null, + targetSubPath: null + }; + } + + async function resolveFileTreeUploadTarget(detail) { + try { + return await preflightFileTreeUploadTarget(detail || {}); + } catch (error) { + console.warn('[mnote upload] upload target preflight fallback', error); + return fallbackFileTreeUploadTarget(detail || {}); + } + } + + function uploadedAssetTitle(asset) { + return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件'; + } + + function uploadedAssetUrl(asset) { + return String(asset && (asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); + } + + function uploadedAssetType(asset) { + return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim(); + } + + function uploadedAssetExtension(asset) { + var match = uploadedAssetTitle(asset).toLowerCase().match(/\.([a-z0-9]+)$/); + return match ? match[1] : ''; + } + + function attachmentClassForFileName(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; + if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word'; + if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt'; + if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet'; + if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf'; + return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file'; + } + + function uploadedAttachmentClass(asset) { + return attachmentClassForFileName(uploadedAssetTitle(asset)); + } + + function buildOnlyOfficeAssetOpenUrl(asset, userId) { + var title = uploadedAssetTitle(asset); + var fileType = inferOnlyOfficeFileType(title, asset && asset.mime_type); + if (!fileType) return ''; + var assetId = String(asset && asset.id || '').trim(); + return buildOnlyOfficeOpenUrl({ + fileUrl: assetId ? '' : uploadedAssetUrl(asset), + fileName: title, + fileType: fileType, + assetId: assetId, + documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(), + userId: userId || '', + mode: 'edit' + }); + } + + function uploadedFileSize(asset) { + var size = Number(asset && (asset.file_size || asset.fileSize) || 0); + if (!Number.isFinite(size) || size <= 0) return ''; + if (size >= 1024 * 1024) return (size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2) + ' MB'; + if (size >= 1024) return (size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2) + ' KB'; + return String(Math.round(size)) + ' B'; + } + + var attachmentMetaCache = Object.create(null); + var attachmentMetaPending = Object.create(null); + var legacyOfficeAttachmentIndex = null; + var legacyOfficeAttachmentIndexPending = null; + + function parseCurrentWorkspaceId() { + return (new URLSearchParams(window.location.search).get('workspaceId') || '').trim(); + } + + async function fetchLegacyOfficeAttachmentIndex() { + if (legacyOfficeAttachmentIndex) return legacyOfficeAttachmentIndex; + if (legacyOfficeAttachmentIndexPending) return legacyOfficeAttachmentIndexPending; + var documentId = currentDocumentId(); + var workspaceId = parseCurrentWorkspaceId(); + if (!documentId || !workspaceId) { + legacyOfficeAttachmentIndex = Object.create(null); + return legacyOfficeAttachmentIndex; + } + legacyOfficeAttachmentIndexPending = fetch( + '/api/tree/projections/file?documentId=' + encodeURIComponent(documentId) + '&workspaceId=' + encodeURIComponent(workspaceId), + { + method: 'GET', + credentials: 'include', + cache: 'no-store' + } + ).then(function(response) { + return response.json().catch(function() { return null; }).then(function(payload) { + var items = payload && payload.ok && payload.result && Array.isArray(payload.result.items) + ? payload.result.items + : []; + var index = Object.create(null); + items.forEach(function(item) { + if (!item || item.rowKind !== 'asset') return; + var title = String(item.title || '').trim(); + if (!title || index[title]) return; + var fileType = inferOnlyOfficeFileType(title, ''); + if (!fileType) return; + var rowId = String(item.rowId || '').trim(); + var assetId = String(item.assetId || '').trim(); + if (!assetId && rowId.indexOf('asset:') === 0) assetId = rowId.slice('asset:'.length); + if (!assetId) return; + index[title] = { + assetId: assetId, + fileName: title, + fileType: fileType, + documentId: documentId + }; + }); + legacyOfficeAttachmentIndex = index; + return index; + }); + }).catch(function() { + var empty = Object.create(null); + legacyOfficeAttachmentIndex = empty; + return empty; + }).finally(function() { + legacyOfficeAttachmentIndexPending = null; + }); + return legacyOfficeAttachmentIndexPending; + } + + async function healLegacyOfficeAttachmentParagraphs() { + var editor = document.querySelector('.editor-surface .ProseMirror'); + if (!(editor instanceof HTMLElement)) return; + var index = await fetchLegacyOfficeAttachmentIndex(); + var paragraphs = Array.from(editor.querySelectorAll('p')); + paragraphs.forEach(function(paragraph) { + if (!(paragraph instanceof HTMLParagraphElement)) return; + if (paragraph.querySelector('a, img, video, audio, table, iframe, canvas')) return; + if (paragraph.childNodes.length !== 1 || paragraph.firstChild?.nodeType !== Node.TEXT_NODE) return; + var fileName = String(paragraph.textContent || '').trim(); + if (!fileName) return; + var detail = index[fileName]; + if (!detail) return; + var link = document.createElement('a'); + link.textContent = fileName; + link.setAttribute('href', buildOnlyOfficeOpenPath({ + fileUrl: '', + fileName: detail.fileName, + fileType: detail.fileType, + assetId: detail.assetId, + documentId: detail.documentId || currentDocumentId() || '', + userId: '', + mode: 'edit' + })); + link.setAttribute('data-mnote-attachment-link', 'true'); + link.setAttribute('data-asset-id', detail.assetId); + attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { + if (name) link.classList.add(name); + }); + paragraph.replaceChildren(link); + enhanceEditorAttachmentLink(link); + }); + } + + function applyEditorAttachmentMeta(link, meta) { + if (!(link instanceof HTMLAnchorElement) || !meta) return; + if (meta.assetId) link.setAttribute('data-asset-id', meta.assetId); + if (meta.fileSize) link.setAttribute('data-file-size', meta.fileSize); + } + + async function hydrateEditorAttachmentMeta(link) { + if (!(link instanceof HTMLAnchorElement)) return; + var detail = detailFromEditorAttachmentLink(link); + var assetId = String(detail && detail.assetId || '').trim(); + if (!assetId) return; + if (attachmentMetaCache[assetId]) { + applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]); + return; + } + if (attachmentMetaPending[assetId]) { + try { await attachmentMetaPending[assetId]; } catch (_) {} + applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]); + return; + } + attachmentMetaPending[assetId] = fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), { + method: 'GET', + credentials: 'include', + cache: 'no-store' + }).then(function(response) { + return response.json().catch(function() { return null; }).then(function(payload) { + if (!response.ok || !payload) return null; + var asset = payload.asset && typeof payload.asset === 'object' ? payload.asset : {}; + var meta = { + assetId: assetId, + fileSize: uploadedFileSize(asset) + }; + attachmentMetaCache[assetId] = meta; + return meta; + }); + }).catch(function() { + return null; + }).finally(function() { + delete attachmentMetaPending[assetId]; + }); + try { + var meta = await attachmentMetaPending[assetId]; + applyEditorAttachmentMeta(link, meta); + } catch (_) {} + } + + function revealFileTreeRow(row) { + if (!(row instanceof HTMLElement)) return; + var node = row.closest('.tree-node'); + while (node && node.parentElement) { + if (node.parentElement.classList && node.parentElement.classList.contains('tree-children')) { + node.parentElement.classList.remove('tree-children--collapsed'); + var parentNode = node.parentElement.closest('.tree-node'); + var parentRow = parentNode ? parentNode.querySelector(':scope > .tree-row') : null; + if (parentRow instanceof HTMLElement) { + parentRow.setAttribute('aria-expanded', 'true'); + var toggle = parentRow.querySelector('[data-rust-action="toggle"]'); + if (toggle) toggle.setAttribute('aria-expanded', 'true'); + } + } + node = node.parentElement.closest('.tree-node'); + } + try { row.scrollIntoView({ block: 'nearest' }); } catch (_) {} + } + + function revealFileTreeAssetRow(assetId) { + if (!assetId) return false; + var row = document.querySelector('.tree-row[data-shell-mode="filetree"][data-asset-id="' + cssEscape(assetId) + '"]'); + if (!(row instanceof HTMLElement)) return false; + revealFileTreeRow(row); + return true; + } + + function appendUploadedAssetRow(asset, documentId) { + var assetId = String(asset && asset.id || '').trim(); + if (!assetId) return; + if (revealFileTreeAssetRow(assetId)) return; + var targetDocumentId = String(documentId || asset.document_id || asset.documentId || currentDocumentId() || '').trim(); + var parentRow = targetDocumentId + ? document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + targetDocumentId) + '"]') + : null; + if (!parentRow) parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-kind="document"]'); + var root = document.querySelector('#sidebar-file-tree-root .tree-root'); + if (!root && !parentRow) return; + var parentLi = parentRow ? parentRow.closest('.tree-node') : null; + var children = parentLi ? parentLi.querySelector(':scope > .tree-children') : null; + if (parentLi && !children) { + children = document.createElement('ul'); + children.className = 'tree-children'; + parentLi.appendChild(children); + } + if (children) { + children.classList.remove('tree-children--collapsed'); + if (parentRow) { + parentRow.setAttribute('aria-expanded', 'true'); + var toggle = parentRow.querySelector('[data-rust-action="toggle"]'); + if (toggle) toggle.setAttribute('aria-expanded', 'true'); + } + } + var container = children || root; + var li = document.createElement('li'); + li.className = 'tree-node'; + li.setAttribute('data-node-id', 'asset:' + assetId); + var title = uploadedAssetTitle(asset); + var iconKind = uploadedAssetType(asset) || 'file'; + li.innerHTML = + ''; + container.appendChild(li); + revealFileTreeRow(li.querySelector('.tree-row')); + document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId); + } + + async function insertUploadedAssetIntoEditor(asset) { + var editorRoot = document.querySelector('.editor-surface .ProseMirror'); + var editor = editorRoot && editorRoot.editor; + if (!editor || !editor.chain) return false; + var title = uploadedAssetTitle(asset); + var url = uploadedAssetUrl(asset); + var type = uploadedAssetType(asset); + var assetId = String(asset && asset.id || '').trim(); + var sizeLabel = uploadedFileSize(asset); + try { + if (type === 'image' && url) { + return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true; + } + var userId = ''; + var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId); + if (onlyOfficeUrl && assetId) { + userId = await fetchCurrentOnlyOfficeUserId(); + onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId); + } + var href = onlyOfficeUrl || url; + if (href) { + var storedHref = onlyOfficeUrl + ? buildOnlyOfficeOpenPath({ + fileUrl: '', + fileName: title, + fileType: inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx', + assetId: assetId, + documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(), + userId: userId || '', + mode: 'edit' + }) + : 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) + } + }] + }] + }).run() === true; + window.setTimeout(function() { + enhanceEditorAttachmentLinks(); + var selector = assetId + ? '.editor-surface .ProseMirror a[href*="' + cssEscape(assetId) + '"]' + : '.editor-surface .ProseMirror a'; + var link = document.querySelector(selector); + if (link instanceof HTMLElement) { + link.setAttribute('data-mnote-attachment-link', 'true'); + if (assetId) link.setAttribute('data-asset-id', assetId); + if (sizeLabel) link.setAttribute('data-file-size', sizeLabel); + } + }, 0); + return inserted; + } + } catch (error) { + console.warn('[mnote upload] insert uploaded asset failed', error); + } + return false; + } + + async function uploadFileToMediaAsset(file, plan, options) { + var form = new FormData(); + form.append('file', file); + 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', { + method: 'POST', + credentials: 'include', + body: form + }); + 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); + } + window.dispatchEvent(new CustomEvent('wolai:assets-changed', { + detail: { docId: plan.targetDocumentId, asset: payload.asset, assetIds: [payload.asset.id] } + })); + return payload.asset; + } + + async function uploadFilesWithResolvedTarget(files, detail, options) { + var list = Array.from(files || []).filter(Boolean); + if (!list.length) return []; + var plan = await resolveFileTreeUploadTarget(detail || {}); + var uploaded = []; + var errors = []; + for (var i = 0; i < list.length; i += 1) { + try { + uploaded.push(await uploadFileToMediaAsset(list[i], plan, options || {})); + } catch (error) { + errors.push(list[i].name + ': ' + (error && error.message ? error.message : '上传失败')); + } + } + if (errors.length) { + window.alert('部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : '')); + } + return uploaded; + } + + function openEditorUploadFilePicker(detail) { + var input = document.createElement('input'); + input.type = 'file'; + input.multiple = detail && detail.multiple !== false; + if (detail && detail.accept) input.accept = String(detail.accept); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + input.style.top = '-9999px'; + document.body.appendChild(input); + input.addEventListener('change', function() { + var files = Array.from(input.files || []); + input.remove(); + void uploadFilesWithResolvedTarget(files, { + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + targetRowId: null + }, { + insertIntoEditor: detail && detail.insertIntoEditor !== false + }); + }, { once: true }); + input.click(); + } + + window.addEventListener('mnote:editor-upload-request', function(event) { + openEditorUploadFilePicker(event.detail || {}); + }); + + window.addEventListener('tree.filetree.external-drop', function(event) { + var detail = event.detail || {}; + void uploadFilesWithResolvedTarget(detail.files || [], detail, { + insertIntoEditor: String(detail.documentId || '') === currentDocumentId() + }); + }); + + document.addEventListener('dragover', function(event) { + var editorTarget = closestAction(event.target, '[data-testid="mnote-leptos-tiptap-editor-stage"], .editor-surface .ProseMirror'); + var hasFiles = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], 'Files') >= 0; + if (!editorTarget || !hasFiles) return; + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + }, true); + + document.addEventListener('drop', function(event) { + var editorTarget = closestAction(event.target, '[data-testid="mnote-leptos-tiptap-editor-stage"], .editor-surface .ProseMirror'); + var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : []; + if (!editorTarget || !files.length) return; + event.preventDefault(); + event.stopPropagation(); + void uploadFilesWithResolvedTarget(files, { + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + targetRowId: null + }, { + insertIntoEditor: true + }); + }, true); + function rowTitle(row) { var title = row ? row.querySelector(':scope > .tree-link > .tree-link-title') : null; return title && title.textContent ? title.textContent.trim() : '无标题'; @@ -1109,6 +1713,31 @@ const SIDEBAR_TREE_JS: &str = r##" function handleTreeContextMenuAction(action, detail, trigger) { closeTreeContextMenu(); + detail = detail || {}; + if (detail.contextKind === 'attachment') { + if (action === 'copy-link') { + void copyTreeContextValue(detail.href || '', 'attachment-copy-link'); + return; + } + if (action === 'download') { + openEditorAttachmentDownload(detail); + return; + } + if (action === 'popup-preview') { + openEditorAttachmentDetail(detail); + return; + } + if (action === 'right-preview') { + dispatchSidebarEvent('tree.attachment.open-right', detail); + return; + } + if (action === 'copy-id') { + void copyTreeContextValue(detail.assetId || '', 'attachment-copy-id'); + return; + } + dispatchSidebarEvent('tree.attachment.action', { action: action, attachment: detail }); + return; + } var documentId = detail.documentId || ''; var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body); var title = detail.title || '无标题'; @@ -1221,13 +1850,32 @@ const SIDEBAR_TREE_JS: &str = r##" function openTreeContextMenu(kind, detail, x, y, trigger) { closeTreeContextMenu(); + detail = Object.assign({}, detail || {}, { contextKind: kind }); var menu = document.createElement('div'); menu.className = 'mnote-tree-context-menu'; menu.setAttribute('role', 'menu'); menu.setAttribute('data-testid', 'mnote-tree-context-menu'); menu.setAttribute('data-kind', kind); + var isAttachment = kind === 'attachment'; var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index'; - var items = isAsset ? [ + var items = isAttachment ? [ + { action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' }, + { action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }, + { separator: true }, + { action: 'copy-link', icon: 'link', label: '复制链接' }, + { action: 'move-embed', icon: 'subdirectory_arrow_right', label: '移动/嵌入到...', shortcut: 'Alt+Shift+M/G' }, + { action: 'history', icon: 'history', label: '块历史...' }, + { separator: true }, + { action: 'popup-preview', icon: 'preview', label: '弹窗预览' }, + { action: 'right-preview', icon: 'right_panel_open', label: '右侧预览' }, + { action: 'download', icon: 'download', label: '下载' }, + { action: 'replace-file', icon: 'sync', label: '更换文件' }, + { action: 'rename-attachment', icon: 'drive_file_rename_outline', label: '重命名' }, + { action: 'comment', icon: 'mode_comment', label: '评论', shortcut: 'Ctrl+Alt+M' }, + { action: 'caption', icon: 'notes', label: '添加说明文字' }, + { separator: true }, + { action: 'color', icon: 'format_paint', label: '颜色' } + ] : isAsset ? [ { action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' }, { action: 'move', icon: 'drive_file_move', label: '移动到...' }, { action: 'copy-id', icon: 'tag', label: '复制资源 ID' } @@ -2244,10 +2892,242 @@ const SIDEBAR_TREE_JS: &str = r##" else openPageSettingsPopover(); } + function attachmentQueryParams(href) { + try { + return new URL(String(href || ''), window.location.origin).searchParams; + } catch (_) { + return new URLSearchParams(); + } + } + + function isOnlyOfficeAttachmentHref(href) { + try { + var url = new URL(String(href || ''), window.location.origin); + return url.pathname === '/onlyoffice' && (url.searchParams.has('assetId') || url.searchParams.has('fileName')); + } catch (_) { + return false; + } + } + + function normalizeOnlyOfficeAttachmentHref(href) { + try { + var url = new URL(String(href || ''), window.location.origin); + if (url.pathname !== '/onlyoffice') return String(href || ''); + return buildOnlyOfficeOpenUrl({ + fileUrl: url.searchParams.get('fileUrl') || '', + fileName: url.searchParams.get('fileName') || '未命名附件', + fileType: url.searchParams.get('fileType') || inferOnlyOfficeFileType(url.searchParams.get('fileName') || '', ''), + assetId: url.searchParams.get('assetId') || '', + documentId: url.searchParams.get('documentId') || currentDocumentId() || '', + userId: url.searchParams.get('userId') || '', + mode: url.searchParams.get('mode') || 'edit' + }); + } catch (_) { + return String(href || ''); + } + } + + function isOfficeFileName(fileName) { + return Boolean(inferOnlyOfficeFileType(fileName, '')); + } + + function detailFromEditorAttachmentLink(link) { + var rawHref = link instanceof HTMLAnchorElement ? link.href : ''; + var params = attachmentQueryParams(rawHref); + var fileName = params.get('fileName') || (link ? link.textContent : '') || '未命名附件'; + var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, ''); + var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || ''; + var fileUrl = params.get('fileUrl') || ''; + var href = rawHref; + if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) { + fileUrl = rawHref; + href = buildOnlyOfficeOpenUrl({ + fileUrl: fileUrl, + fileName: fileName, + fileType: fileType, + assetId: assetId, + documentId: currentDocumentId() || '', + userId: '', + mode: 'edit' + }); + } else if (isOnlyOfficeAttachmentHref(rawHref)) { + href = normalizeOnlyOfficeAttachmentHref(rawHref); + } + return { + href: href, + fileUrl: fileUrl, + fileName: fileName, + title: fileName, + fileType: fileType, + assetId: assetId, + documentId: params.get('documentId') || currentDocumentId() || '', + workspaceId: resolveWorkspaceId(document.body), + fileSize: (link ? link.getAttribute('data-file-size') : '') || '' + }; + } + + function enhanceEditorAttachmentLink(link) { + if (!(link instanceof HTMLAnchorElement)) return; + var href = link.getAttribute('href') || ''; + var params = attachmentQueryParams(href); + var fileName = params.get('fileName') || link.textContent || ''; + var className = link.getAttribute('class') || ''; + var shouldEnhance = isOnlyOfficeAttachmentHref(href) + || className.indexOf('mnote-uploaded-attachment-row') >= 0 + || isOfficeFileName(fileName); + if (!shouldEnhance) return; + link.setAttribute('data-mnote-attachment-link', 'true'); + var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || ''; + if (assetId) link.setAttribute('data-asset-id', assetId); + if (isOnlyOfficeAttachmentHref(href)) { + link.setAttribute('href', buildOnlyOfficeOpenPath({ + fileUrl: params.get('fileUrl') || '', + fileName: fileName || '未命名附件', + fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''), + assetId: assetId, + documentId: params.get('documentId') || currentDocumentId() || '', + userId: params.get('userId') || '', + mode: params.get('mode') || 'edit' + })); + } + attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { + if (name) link.classList.add(name); + }); + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer nofollow'); + void hydrateEditorAttachmentMeta(link); + } + + function enhanceEditorAttachmentLinks() { + document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink); + void healLegacyOfficeAttachmentParagraphs(); + } + + function ensureAttachmentActions() { + var existing = document.querySelector('[data-testid="mnote-attachment-actions"]'); + if (existing instanceof HTMLElement) return existing; + var actions = document.createElement('div'); + actions.className = 'mnote-attachment-actions'; + actions.setAttribute('data-testid', 'mnote-attachment-actions'); + actions.hidden = true; + actions.innerHTML = '' + + '' + + ''; + actions.addEventListener('mouseenter', function() { + if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer); + }); + actions.addEventListener('mouseleave', scheduleHideAttachmentActions); + document.body.appendChild(actions); + return actions; + } + + function positionAttachmentActions(link) { + if (!(link instanceof HTMLElement)) return; + var actions = ensureAttachmentActions(); + var rect = link.getBoundingClientRect(); + actions.hidden = false; + actions.style.left = Math.min(window.innerWidth - 76, Math.max(8, rect.right + 6)) + 'px'; + actions.style.top = Math.max(8, rect.top + (rect.height - 28) / 2) + 'px'; + activeEditorAttachmentLink = link; + } + + function scheduleHideAttachmentActions() { + if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer); + attachmentActionsHideTimer = window.setTimeout(function() { + var actions = document.querySelector('[data-testid="mnote-attachment-actions"]'); + if (actions instanceof HTMLElement) actions.hidden = true; + activeEditorAttachmentLink = null; + }, 220); + } + + function openEditorAttachmentDetail(detail) { + if (!detail || !detail.href) return; + window.open(detail.href, '_blank', 'noopener,noreferrer'); + } + + async function openEditorAttachmentDownload(detail) { + if (!detail) return; + if (detail.assetId) { + try { + var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), { + method: 'GET', + credentials: 'include' + }); + var payload = await response.json().catch(function() { return null; }); + var signedUrl = String(payload && payload.signedUrl || '').trim(); + if (response.ok && signedUrl) { + window.open(signedUrl, '_blank', 'noopener,noreferrer'); + return; + } + } catch (_) {} + } + var target = detail.fileUrl || detail.href; + if (!target) return; + window.open(target, '_blank', 'noopener,noreferrer'); + } + + function openEditorAttachmentMenu(link, trigger) { + var detail = detailFromEditorAttachmentLink(link); + var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : link.getBoundingClientRect(); + openTreeContextMenu('attachment', detail, rect.right, rect.bottom + 4, trigger || link); + } + + function openEditorAttachmentLink(link) { + enhanceEditorAttachmentLink(link); + openEditorAttachmentDetail(detailFromEditorAttachmentLink(link)); + } + + enhanceEditorAttachmentLinks(); + var attachmentObserver = new MutationObserver(function() { enhanceEditorAttachmentLinks(); }); + attachmentObserver.observe(document.documentElement, { childList: true, subtree: 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'); + if (!(link instanceof HTMLAnchorElement)) return; + if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer); + enhanceEditorAttachmentLink(link); + positionAttachmentActions(link); + }); + + 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'); + if (!(link instanceof HTMLAnchorElement)) return; + var next = event.relatedTarget; + var actions = document.querySelector('[data-testid="mnote-attachment-actions"]'); + if (next && (link.contains(next) || (actions && actions.contains(next)))) return; + scheduleHideAttachmentActions(); + }); + document.addEventListener('click', function(e) { if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return; if (activeTreeContextMenu) closeTreeContextMenu(); + var attachmentAction = closestAction(e.target, '[data-attachment-action]'); + if (attachmentAction) { + e.preventDefault(); + e.stopPropagation(); + var link = activeEditorAttachmentLink; + if (!(link instanceof HTMLAnchorElement)) return; + var attachmentDetail = detailFromEditorAttachmentLink(link); + var attachmentActionName = attachmentAction.getAttribute('data-attachment-action') || ''; + if (attachmentActionName === 'download') { + openEditorAttachmentDownload(attachmentDetail); + return; + } + if (attachmentActionName === 'menu') { + openEditorAttachmentMenu(link, attachmentAction); + return; + } + return; + } + + var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + if (editorAttachmentLink instanceof HTMLAnchorElement) { + e.preventDefault(); + openEditorAttachmentLink(editorAttachmentLink); + return; + } + var historyClose = closestAction(e.target, '[data-page-history-action="close"]'); if (historyClose) { e.preventDefault(); @@ -3084,6 +3964,13 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open")); assert!(SIDEBAR_TREE_JS.contains("tree.asset.open")); + assert!(SIDEBAR_TREE_JS.contains("openConvexAssetFromFileTree")); + assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId=")); + assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId")); + assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami")); + assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl")); + assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('userId'")); + assert!(SIDEBAR_TREE_JS.contains("window.open(buildOnlyOfficeOpenUrl")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop")); assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY")); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index cc70bc93..0047c1b5 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -168,13 +168,20 @@ a:hover { .material-symbols-outlined[data-icon="drive_file_move"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h6l2 2h8v10H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m13 12 3 3-3 3M8 15h8' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="delete"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 6h14M9 6V4h6v2M8 6l1 14h6l1-14M10.5 10v6M13.5 10v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="right_panel_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v14H4zM14 5v14M8 12h6M11 9l3 3-3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="preview"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Ccircle cx='12' cy='12' r='2.8' fill='none' stroke='black' stroke-width='2'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="download"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 4v10M8.5 11.5 12 15l3.5-3.5M5 19h14' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="notes"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 5h12v14H6zM9 9h6M9 12h6M9 15h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="open_in_new"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6H5v13h13v-3M12 5h7v7M10 14 19 5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="share"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='18' cy='5' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8.7 10.7 6.6-4.4M8.7 13.3l6.6 4.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="drive_file_rename_outline"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m4 17-.5 3.5L7 20l11-11-3-3zM13 8l3 3M5 6h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.material-symbols-outlined[data-icon="format_paint"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7h10l2 3-2 3H5l-2-3zM9 13v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="link"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.5 14.5 14.5 9.5M10.8 6.2l1.1-1.1a4 4 0 0 1 5.7 5.7l-1.6 1.6M13.2 17.8l-1.1 1.1a4 4 0 0 1-5.7-5.7L8 11.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); } .material-symbols-outlined[data-icon="content_copy"], .material-symbols-outlined[data-icon="file_copy"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 8h10v12H8zM6 16H4V4h10v2' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); } @@ -1750,6 +1757,75 @@ body { background: rgba(27, 28, 28, 0.08); } +.mnote-attachment-actions { + position: fixed; + z-index: 1100; + display: inline-flex; + align-items: center; + gap: 2px; + height: 28px; + padding: 2px; + border: 1px solid rgba(27, 28, 28, 0.08); + border-radius: 6px; + background: #FFFFFF; + box-shadow: 0 6px 18px rgba(27, 28, 28, 0.12); +} + +.mnote-attachment-actions[hidden] { + display: none !important; +} + +.mnote-attachment-action { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: transparent; + color: #6D6A65; + cursor: pointer; +} + +.mnote-attachment-action:hover { + background: #F4F3F3; + color: #37352F; +} + +.mnote-attachment-action .material-symbols-outlined { + font-size: 18px; +} + +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row { + display: inline-flex !important; + align-items: center !important; + gap: 7px !important; + max-width: 100% !important; + min-height: 28px !important; + padding: 2px 4px !important; + border-radius: 4px !important; + color: #37352f !important; + font-weight: 500 !important; + line-height: 1.4 !important; + text-decoration: none !important; +} + +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::after { + content: "" !important; + display: none !important; +} + +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row[data-file-size]::after { + content: "◔ " attr(data-file-size) !important; + display: inline-block !important; + margin-left: 4px !important; + color: #9ca3af !important; + font-size: 12px !important; + line-height: 1 !important; + white-space: nowrap !important; +} + .sidebar-tree .tree-node .tree-children .tree-row { padding-left: 20px; } diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index 77511c25..f79e7c71 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -332,6 +332,29 @@ pub async fn execute_sidebar_dataset_query( execute_convex_query_plan(config, context, plan).await } +pub async fn execute_convex_query_by_name( + config: &AppConfig, + context: &RequestContext, + function_name: &str, + args: Value, + workspace_id: Option<&str>, + error_phase: &'static str, +) -> Result { + let plan = RuntimeQueryExecutionPlan { + query_name: function_name.to_string(), + function_name: function_name.to_string(), + workspace_id: workspace_id.map(ToOwned::to_owned), + request_id: context.trace.request_id.clone(), + trace_id: context.trace.trace_id.clone(), + actor_id: context.auth.actor_id.clone(), + payload_json: args.to_string(), + args_json: args, + }; + execute_convex_query_plan(config, context, &plan) + .await + .map_err(|error| error.with_header("x-error-phase", error_phase)) +} + fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { let mut args = plan.args_json.clone(); if matches!( diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts index aa95fcb3..24b181f7 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts @@ -46,28 +46,29 @@ export interface InitOutput { readonly memory: WebAssembly.Memory; readonly mount: (a: any, b: any) => [number, number, number]; readonly unmount: (a: number) => [number, number]; - readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void; - readonly intounderlyingsink_write: (a: number, b: any) => any; - readonly intounderlyingsink_close: (a: number) => any; - readonly intounderlyingsink_abort: (a: number, b: any) => any; readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; readonly intounderlyingbytesource_type: (a: number) => number; readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; readonly intounderlyingbytesource_start: (a: number, b: any) => void; readonly intounderlyingbytesource_pull: (a: number, b: any) => any; readonly intounderlyingbytesource_cancel: (a: number) => void; + readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void; + readonly intounderlyingsink_write: (a: number, b: any) => any; + readonly intounderlyingsink_close: (a: number) => any; + readonly intounderlyingsink_abort: (a: number, b: any) => any; readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void; readonly intounderlyingsource_pull: (a: number, b: any) => any; readonly intounderlyingsource_cancel: (a: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number]; - readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number; + readonly wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __externref_table_alloc: () => number; diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index e7422952..eefb1775 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -785,7 +785,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1); + return wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -1014,6 +1014,24 @@ function __wbg_get_imports() { const ret = arg0.right; return ret; }, + __wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) { + try { + var state0 = {a: arg1, b: arg2}; + var cb0 = () => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(a, state0.b, ); + } finally { + state0.a = a; + } + }; + const ret = arg0.run(cb0); + return ret; + } finally { + state0.a = 0; + } + }, __wbg_scrollHeight_5fe8cbb97ae906d8: function(arg0) { const ret = arg0.scrollHeight; return ret; @@ -1111,6 +1129,10 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, + __wbg_static_accessor_CREATE_TASK_f3ab6a6954bda493: function() { + const ret = typeof console === 'undefined' ? null : console?.createTask; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, __wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() { const ret = typeof global === 'undefined' ? null : global; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -1188,6 +1210,12 @@ function __wbg_get_imports() { const ret = arg0.view; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, + __wbg_warn_3cc416af27dbdc02: function(arg0) { + console.warn(arg0); + }, + __wbg_warn_bd0f407277b102f4: function(arg0, arg1, arg2) { + console.warn(arg0, arg1, arg2); + }, __wbg_width_9673a519d7bd5a6a: function(arg0) { const ret = arg0.width; return ret; @@ -1205,43 +1233,43 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1025, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1440, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 794, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1721, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1812, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 916, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1638, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1723, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 918, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1637, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 940, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1659, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f); return ret; }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 976, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1722, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9); return ret; }, __wbindgen_cast_0000000000000009: function(arg0) { @@ -1285,43 +1313,48 @@ function __wbg_get_imports() { }; } -function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1); + return ret !== 0; } -function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } } -function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) { - wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3); +function wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3); } diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index de88be41..b1385993 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts index 472384e5..eb1b8672 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts @@ -3,28 +3,29 @@ export const memory: WebAssembly.Memory; export const mount: (a: any, b: any) => [number, number, number]; export const unmount: (a: number) => [number, number]; -export const __wbg_intounderlyingsink_free: (a: number, b: number) => void; -export const intounderlyingsink_write: (a: number, b: any) => any; -export const intounderlyingsink_close: (a: number) => any; -export const intounderlyingsink_abort: (a: number, b: any) => any; export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; export const intounderlyingbytesource_type: (a: number) => number; export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; export const intounderlyingbytesource_start: (a: number, b: any) => void; export const intounderlyingbytesource_pull: (a: number, b: any) => any; export const intounderlyingbytesource_cancel: (a: number) => void; +export const __wbg_intounderlyingsink_free: (a: number, b: number) => void; +export const intounderlyingsink_write: (a: number, b: any) => any; +export const intounderlyingsink_close: (a: number) => any; +export const intounderlyingsink_abort: (a: number, b: any) => any; export const __wbg_intounderlyingsource_free: (a: number, b: number) => void; export const intounderlyingsource_pull: (a: number, b: any) => any; export const intounderlyingsource_cancel: (a: number) => void; -export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void; -export const wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void; -export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void; -export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number; +export const wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __externref_table_alloc: () => number; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 6f4b1179..5e1e2e3a 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -1819,6 +1819,72 @@ const SPIKE_STYLE: &str = r#" text-decoration: none; } +.editor-surface .ProseMirror a.mnote-uploaded-attachment-row { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; + min-height: 28px; + margin: 1px 0; + padding: 2px 4px; + border: 0; + border-radius: 4px; + color: #37352f; + font-weight: 500; + line-height: 1.4; + text-decoration: none; + background: transparent; + box-shadow: none; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before { + content: ""; + width: 18px; + height: 18px; + flex: 0 0 auto; + border-radius: 4px; + background: var(--attachment-icon-bg, #e9ecef); + border: 1px solid var(--attachment-icon-border, rgba(55, 53, 47, 0.14)); +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-row::after { + content: "◉"; + flex: 0 0 auto; + margin-left: 4px; + color: #9ca3af; + font-size: 11px; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-row:hover { + background: rgba(55, 53, 47, 0.06); + text-decoration: none; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-word { + --attachment-icon-bg: #4f82ff; + --attachment-icon-border: #3366d6; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-ppt { + --attachment-icon-bg: #ea581f; + --attachment-icon-border: #cf4817; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-sheet { + --attachment-icon-bg: #2f9e44; + --attachment-icon-border: #25813a; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-pdf { + --attachment-icon-bg: #ef4444; + --attachment-icon-border: #dc2626; +} + +.editor-surface .ProseMirror a.mnote-uploaded-attachment-file { + --attachment-icon-bg: #9ca3af; + --attachment-icon-border: #6b7280; +} + .footer-strip { display: flex; justify-content: space-between; @@ -1964,6 +2030,7 @@ enum SlashActionKind { Divider, SimpleTable, Image, + UploadAttachment, Toc, } @@ -2013,7 +2080,7 @@ const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [ }, ]; -const SLASH_ACTIONS: [SlashAction; 20] = [ +const SLASH_ACTIONS: [SlashAction; 21] = [ SlashAction { kind: SlashActionKind::AiAssistant, id: "ai-assistant", @@ -2182,9 +2249,18 @@ const SLASH_ACTIONS: [SlashAction; 20] = [ category: "媒体与附件", icon: "▧", label: "图片", - description: "插入图片", + description: "上传并插入图片", shortcut: "/tp", }, + SlashAction { + kind: SlashActionKind::UploadAttachment, + id: "upload-attachment", + category: "媒体与附件", + icon: "↥", + label: "上传附件", + description: "上传 Office、PDF 或其他文件", + shortcut: "/fj", + }, SlashAction { kind: SlashActionKind::Toc, id: "toc", @@ -4058,7 +4134,9 @@ fn turn_into_block(node: &Value, action: SlashActionKind) -> Value { node_with_attrs("codeBlock", Some(attrs), content) } SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()), - SlashActionKind::SimpleTable | SlashActionKind::Toc => paragraph_node(inline), + SlashActionKind::SimpleTable | SlashActionKind::UploadAttachment | SlashActionKind::Toc => { + paragraph_node(inline) + } SlashActionKind::Image => { let mut attrs = Map::new(); attrs.insert("src".to_string(), json!(E24_IMAGE_PLACEHOLDER_SRC)); @@ -5437,6 +5515,25 @@ fn table_option_checked(document: &Value, action: TableOptionAction) -> bool { } } +fn dispatch_editor_upload_request(kind: &str, accept: &str) -> Result<(), String> { + let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?; + let detail = json!({ + "kind": kind, + "accept": accept, + "multiple": true, + "insertIntoEditor": true, + }); + let detail = + serde_wasm_bindgen::to_value(&detail).map_err(|err| format!("构造上传请求失败:{err}"))?; + let init = CustomEventInit::new(); + init.set_detail(&detail); + let event = CustomEvent::new_with_event_init_dict("mnote:editor-upload-request", &init) + .map_err(|_| "构造上传事件失败".to_string())?; + win.dispatch_event(&event) + .map(|_| ()) + .map_err(|_| "派发上传事件失败".to_string()) +} + fn run_slash_action( editor: TiptapEditorHandle, action: SlashActionKind, @@ -5464,11 +5561,14 @@ fn run_slash_action( let _ = editor.focus(); editor.insert_table(4, 3, false) } - SlashActionKind::Image => editor.set_image(TiptapImageResource { - src: E24_IMAGE_PLACEHOLDER_SRC.into(), - alt: Some(E24_IMAGE_PLACEHOLDER_ALT.into()), - title: Some(E24_IMAGE_PLACEHOLDER_TITLE.into()), - }), + SlashActionKind::Image => { + dispatch_editor_upload_request("image", "image/*")?; + return Ok("已打开图片上传"); + } + SlashActionKind::UploadAttachment => { + dispatch_editor_upload_request("attachment", "")?; + return Ok("已打开附件上传"); + } SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs { top_offset: Some(0), max_show_count: Some(20), @@ -5495,7 +5595,8 @@ fn run_slash_action( SlashActionKind::CodeBlock => "已切到代码块", SlashActionKind::Divider => "已插入分割线", SlashActionKind::SimpleTable => "已插入简单表格", - SlashActionKind::Image => "已插入图片", + SlashActionKind::Image => "已打开图片上传", + SlashActionKind::UploadAttachment => "已打开附件上传", SlashActionKind::Toc => "已插入页面目录", }) .map_err(|err| format!("命令执行失败:{err}")) @@ -5844,6 +5945,7 @@ fn top_level_block_matches_action( SlashActionKind::Divider => node_type == "horizontalRule", SlashActionKind::SimpleTable => node_type == "table", SlashActionKind::Image => node_type == "image", + SlashActionKind::UploadAttachment => false, SlashActionKind::Toc => node_type == "tocNode", SlashActionKind::AiAssistant | SlashActionKind::AiWrite @@ -9052,7 +9154,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
{SLASH_ACTIONS .iter() - .filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::Toc | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi)) + .filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::UploadAttachment | SlashActionKind::Toc | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi)) .map(|action| { let kind = action.kind; let label = action.label; diff --git a/scripts/desktop-hot.js b/scripts/desktop-hot.js index 4e893e9d..5572cb77 100644 --- a/scripts/desktop-hot.js +++ b/scripts/desktop-hot.js @@ -1,10 +1,12 @@ #!/usr/bin/env node /** - * 同时热启动前端、FastAPI,以及按需启用的 Celery。 + * 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery。 * 可使用以下环境变量调整行为: * - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效 - * - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000" + * - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端 + * - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端 + * - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端 * - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker * - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery * - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值 @@ -13,7 +15,7 @@ * - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0" * - SKIP_CELERY:设为 "1" or "true" 可强制跳过 Celery。 * - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。 - * - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:设为 "1" or "true" 时才启动 Next legacy upstream。 + * - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:已废弃;desktop:hot 默认不再启动 Next legacy upstream。 * - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。 * - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。 */ @@ -72,19 +74,21 @@ function shouldStartCelery(env = process.env) { return isEnabledEnv(env.ENABLE_CELERY); } +function shouldStartBackend(env = process.env) { + if (isEnabledEnv(env.SKIP_BACKEND)) return false; + if (String(env.BACKEND_CMD || "").trim()) return true; + return isEnabledEnv(env.ENABLE_BACKEND); +} + function resolveRuntimePlan(env = process.env) { const frontendPort = Number(env.FRONTEND_PORT || 3000); const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100); const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY); - const legacyCompatRequested = - !skipGateway && - isEnabledEnv(env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT) && - !isEnabledEnv(env.SKIP_NEXT_LEGACY); - const skipNextLegacy = !skipGateway && !legacyCompatRequested; + const skipNextLegacy = !skipGateway; const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000; const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100; const legacyUrl = `http://127.0.0.1:${legacyPort}`; - const legacyCompatEnabled = legacyCompatRequested ? "1" : "0"; + const legacyCompatEnabled = "0"; return { skipGateway, @@ -93,17 +97,14 @@ function resolveRuntimePlan(env = process.env) { legacyPort, publicUrl: `http://localhost:${publicPort}`, legacyUrl, - frontendTaskName: skipGateway ? "frontend" : skipNextLegacy ? null : "next-legacy", + frontendTaskName: skipGateway ? "frontend" : null, frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`, mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web", mnoteWebEnv: skipGateway ? {} : { - MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `127.0.0.1:${publicPort}`, + MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`, MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`, - ...(skipNextLegacy - ? {} - : { MNOTE_WEB_LEGACY_NEXT_BASE_URL: env.MNOTE_WEB_LEGACY_NEXT_BASE_URL || legacyUrl }), MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled, }, }; @@ -135,13 +136,17 @@ const tasks = [ cwd: path.join(rootDir, "rust"), }, ]), - { - name: "backend", - command: - process.env.BACKEND_CMD || - `${pythonBin} -m uvicorn app.main:app --reload --port 8000`, - cwd: backendDir, - }, + ...(shouldStartBackend(process.env) + ? [ + { + name: "backend", + command: + process.env.BACKEND_CMD || + `${pythonBin} -m uvicorn app.main:app --reload --port 8000`, + cwd: backendDir, + }, + ] + : []), ]; function findTask(name) { @@ -549,7 +554,7 @@ async function main() { if (runtimePlan.frontendTaskName === "next-legacy") { logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`); } else { - logPrefix("next-legacy", "默认不启动历史 Next upstream;如需临时兼容请设置 MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=1。"); + logPrefix("next-legacy", "desktop:hot 默认不启动历史 Next upstream,3000 由 Rust mnote-web 独占。"); } const gatewayTask = tasks.find((task) => task.name === "mnote-web"); if (gatewayTask) { @@ -559,7 +564,7 @@ async function main() { } const desiredBackendPort = backendPortFromEnv; - if (!process.env.BACKEND_CMD) { + if (shouldStartBackend(process.env) && !process.env.BACKEND_CMD) { const backendPortOk = await ensurePortFree(desiredBackendPort, "backend"); if (!backendPortOk) { console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`); @@ -570,6 +575,10 @@ async function main() { throw new Error("缺少后端任务配置"); } backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`; + } else if (isEnabledEnv(process.env.SKIP_BACKEND)) { + logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。"); + } else if (!shouldStartBackend(process.env)) { + logPrefix("backend", "默认不启动 FastAPI 后端;desktop:hot 保持 3000 单入口。如需启用请设置 ENABLE_BACKEND=1 或 BACKEND_CMD。"); } if (shouldStartCelery(process.env)) { @@ -625,6 +634,7 @@ module.exports = { isPortFree, resolveRuntimePlan, resolveBackendExecutable, + shouldStartBackend, shouldStartCelery, terminatePid, }; diff --git a/scripts/desktop-hot.test.js b/scripts/desktop-hot.test.js index 28497a2a..a946c7f5 100644 --- a/scripts/desktop-hot.test.js +++ b/scripts/desktop-hot.test.js @@ -7,6 +7,7 @@ const { ensurePortFree, isPortFree, resolveRuntimePlan, + shouldStartBackend, shouldStartCelery, } = require("./desktop-hot.js"); @@ -128,13 +129,13 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner,不启动 Nex assert.equal(plan.frontendTaskName, null); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.deepEqual(plan.mnoteWebEnv, { - MNOTE_WEB_BIND: "127.0.0.1:3000", + MNOTE_WEB_BIND: "0.0.0.0:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", }); }); -test("显式开启 legacy compat 时才启动 Next legacy upstream", () => { +test("显式 legacy compat 也不再启动 Next legacy upstream", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", NEXT_LEGACY_PORT: "3100", @@ -142,15 +143,13 @@ test("显式开启 legacy compat 时才启动 Next legacy upstream", () => { }); assert.equal(plan.skipGateway, false); - assert.equal(plan.skipNextLegacy, false); - assert.equal(plan.frontendTaskName, "next-legacy"); - assert.equal(plan.frontendCommand, "pnpm dev -p 3100"); + assert.equal(plan.skipNextLegacy, true); + assert.equal(plan.frontendTaskName, null); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.deepEqual(plan.mnoteWebEnv, { - MNOTE_WEB_BIND: "127.0.0.1:3000", + MNOTE_WEB_BIND: "0.0.0.0:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", }); }); @@ -180,7 +179,7 @@ test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next le assert.equal(plan.frontendTaskName, null); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.deepEqual(plan.mnoteWebEnv, { - MNOTE_WEB_BIND: "127.0.0.1:3000", + MNOTE_WEB_BIND: "0.0.0.0:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", }); @@ -193,3 +192,11 @@ test("默认跳过 Celery,只有显式开启时才启动", () => { assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true); assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false); }); + +test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => { + assert.equal(shouldStartBackend({}), false); + assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true); + assert.equal(shouldStartBackend({ ENABLE_BACKEND: "true" }), true); + assert.equal(shouldStartBackend({ BACKEND_CMD: "custom-backend" }), true); + assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false); +}); diff --git a/scripts/task174-rust-onlyoffice-attachment-open-smoke.js b/scripts/task174-rust-onlyoffice-attachment-open-smoke.js new file mode 100644 index 00000000..79bcebfc --- /dev/null +++ b/scripts/task174-rust-onlyoffice-attachment-open-smoke.js @@ -0,0 +1,267 @@ +"use strict"; + +// 说明: +// - 验证 Rust 3000 主入口的 Convex 附件行打开 OnlyOffice 链路。 +// - 脚本会创建临时页面、上传临时 docx、点击文件树附件行并断言新窗口打开 /onlyoffice。 +// - 结束后清理临时页面,避免污染长期测试空间。 + +const fs = require("node:fs"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + assert, + createTempDocument, + ensureAuthenticated, + openDocument, + openFilesystemView, + purgeDocument, +} = require("./tree-shell-smoke-helpers"); + +const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +const ONLYOFFICE_READY_TIMEOUT_MS = Number(process.env.MNOTE_ONLYOFFICE_READY_TIMEOUT_MS || 120_000); +const PROBE_DOCX_PATH = + process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || + "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx"; +const TEST_EMAIL = "mnote.e2e@example.com"; +const TEST_PASSWORD = "MnoteE2E123!"; +let authCookieHeader = ""; +const popupConsoleMessages = []; +const popupNetworkFailures = []; + +function parseSetCookie(setCookie, origin) { + const [nameValue] = String(setCookie || "").split(";"); + const separator = nameValue.indexOf("="); + if (separator <= 0) return null; + return { + name: nameValue.slice(0, separator).trim(), + value: nameValue.slice(separator + 1).trim(), + domain: new URL(origin).hostname, + path: "/", + httpOnly: /;\s*httponly\b/i.test(setCookie), + sameSite: "Lax", + }; +} + +async function forceTestAccountLogin(context) { + const response = await context.request.fetch(`${BASE_URL}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: TEST_EMAIL, + password: TEST_PASSWORD, + flow: "signIn", + }, + }, + }, + headers: { "content-type": "application/json" }, + timeout: UI_TIMEOUT_MS, + }); + const payload = await response.json().catch(async () => ({ raw: await response.text() })); + assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`); + + const cookies = response + .headersArray() + .filter((header) => header.name.toLowerCase() === "set-cookie") + .map((header) => parseSetCookie(header.value, BASE_URL)) + .filter(Boolean); + assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie"); + await context.addCookies(cookies); + authCookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); +} + +async function uploadProbeDocx(requestContext, target) { + assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`); + const buffer = fs.readFileSync(PROBE_DOCX_PATH); + const response = await requestContext.fetch(`${BASE_URL}/api/media/upload`, { + method: "POST", + headers: authCookieHeader ? { cookie: authCookieHeader } : undefined, + multipart: { + file: { + name: "task174-onlyoffice-attachment.docx", + mimeType: DOCX_MIME, + buffer, + }, + workspaceId: target.workspaceId, + documentId: target.documentId, + }, + timeout: UI_TIMEOUT_MS, + }); + const payload = await response.json().catch(async () => ({ raw: await response.text() })); + assert( + response.ok(), + `/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`, + ); + assert( + payload && payload.asset && typeof payload.asset.id === "string", + `上传结果缺少 asset.id:${JSON.stringify(payload)}`, + ); + return payload.asset; +} + +async function waitForAssetRow(page, assetId) { + const selector = `[data-testid="filetree-asset-row"][data-asset-id="${assetId}"]`; + const row = page.locator(selector).first(); + await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + return row; +} + +async function waitForOnlyOfficeReady(page) { + page.on("console", (message) => { + popupConsoleMessages.push({ + type: message.type(), + text: message.text().slice(0, 1000), + }); + }); + page.on("pageerror", (error) => { + popupConsoleMessages.push({ + type: "pageerror", + text: error.message.slice(0, 1000), + }); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + popupNetworkFailures.push({ + status: response.status(), + url: response.url().slice(0, 1000), + }); + } + }); + try { + await page.waitForFunction( + () => window.__MNOTE_ONLYOFFICE_READY__ === true, + undefined, + { timeout: ONLYOFFICE_READY_TIMEOUT_MS }, + ); + await page.waitForFunction( + () => { + const root = document.getElementById("onlyoffice-frame"); + if (root && root.querySelector("iframe,canvas")) return true; + return Boolean(document.querySelector("iframe,canvas")); + }, + undefined, + { timeout: ONLYOFFICE_READY_TIMEOUT_MS }, + ); + } catch (error) { + const debug = await page.evaluate(() => ({ + url: window.location.href, + title: document.title, + readyState: document.readyState, + ready: window.__MNOTE_ONLYOFFICE_READY__ === true, + debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null, + errors: window.__MNOTE_ONLYOFFICE_ERRLOG__ || [], + hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")), + bodyText: document.body ? document.body.innerText.slice(0, 800) : "", + html: document.body ? document.body.innerHTML.slice(0, 1600) : "", + scriptCount: document.scripts.length, + frameCount: document.querySelectorAll("iframe,canvas").length, + hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")), + })).catch((debugError) => ({ + evaluateError: debugError instanceof Error ? debugError.message : String(debugError), + isClosed: page.isClosed(), + })); + debug.consoleMessages = popupConsoleMessages.slice(-20); + debug.networkFailures = popupNetworkFailures.slice(-30); + throw new Error( + `OnlyOffice ready 超时:${error instanceof Error ? error.message : String(error)} ${JSON.stringify(debug)}`, + ); + } +} + +async function readOnlyOfficeDebug(page) { + return await page.evaluate(() => { + const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null; + return { + debug, + docKey: debug && typeof debug.docKey === "string" ? debug.docKey : "", + ready: window.__MNOTE_ONLYOFFICE_READY__ === true, + frameCount: document.querySelectorAll("iframe,canvas").length, + }; + }); +} + +async function main() { + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const context = await browser.newContext(); + const page = await context.newPage(); + let target = null; + try { + await forceTestAccountLogin(context); + await ensureAuthenticated(page, context.request); + target = await createTempDocument(context.request, null); + const asset = await uploadProbeDocx(context.request, target); + + await openDocument(page, target.workspaceId, target.documentId); + await openFilesystemView(page); + const row = await waitForAssetRow(page, asset.id); + + const [popup] = await Promise.all([ + page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }), + row.locator('[data-rust-action="open"]').first().click({ timeout: UI_TIMEOUT_MS }), + ]); + await popup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + + const opened = new URL(popup.url()); + assert(opened.pathname === "/onlyoffice", `附件应在新窗口打开 /onlyoffice,实际为:${popup.url()}`); + assert(opened.searchParams.get("assetId") === asset.id, "OnlyOffice URL 缺少正确 assetId"); + assert( + opened.searchParams.get("documentId") === target.documentId, + "OnlyOffice URL 缺少正确 documentId", + ); + assert(opened.searchParams.get("fileType") === "docx", "OnlyOffice URL fileType 应为 docx"); + assert(opened.searchParams.get("mode") === "edit", "OnlyOffice URL mode 应为 edit"); + await waitForOnlyOfficeReady(popup); + const firstDebug = await readOnlyOfficeDebug(popup); + assert(firstDebug.ready, "OnlyOffice ready flag 应为 true"); + assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas"); + assert( + /^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey), + `OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`, + ); + assert( + firstDebug.docKey === asset.id || firstDebug.docKey.startsWith(`${asset.id}_`), + `OnlyOffice document.key 应绑定 assetId,实际为:${firstDebug.docKey}`, + ); + + const reopened = await context.newPage(); + await reopened.goto(popup.url(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForOnlyOfficeReady(reopened); + const secondDebug = await readOnlyOfficeDebug(reopened); + assert( + secondDebug.docKey === firstDebug.docKey, + `同一附件重复打开 document.key 应稳定:first=${firstDebug.docKey} second=${secondDebug.docKey}`, + ); + await reopened.close().catch(() => undefined); + + console.log( + JSON.stringify( + { + ok: true, + documentId: target.documentId, + workspaceId: target.workspaceId, + assetId: asset.id, + openedUrl: popup.url(), + docKey: firstDebug.docKey, + }, + null, + 2, + ), + ); + } finally { + if (target && target.documentId) { + await purgeDocument(context.request, target.documentId).catch((error) => { + console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`); + }); + } + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task175-rust-upload-entry-smoke.js b/scripts/task175-rust-upload-entry-smoke.js new file mode 100644 index 00000000..8abab0fe --- /dev/null +++ b/scripts/task175-rust-upload-entry-smoke.js @@ -0,0 +1,367 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + assert, + createTempDocument, + ensureAuthenticated, + openDocument, + openFilesystemView, + purgeDocument, +} = require("./tree-shell-smoke-helpers"); + +const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +const PNG_MIME = "image/png"; +const TEST_EMAIL = "mnote.e2e@example.com"; +const TEST_PASSWORD = "MnoteE2E123!"; +const PROBE_DOCX_PATH = + process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || + "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx"; +const SCREENSHOT_DIR = + process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR || + "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry"; + +function ensureProbeDocx() { + assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件:${PROBE_DOCX_PATH}`); + return { + name: "task175-slash-attachment.docx", + mimeType: DOCX_MIME, + buffer: fs.readFileSync(PROBE_DOCX_PATH), + }; +} + +function tinyPngBuffer() { + return Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + "base64", + ); +} + +function parseSetCookie(setCookie, origin) { + const [nameValue] = String(setCookie || "").split(";"); + const separator = nameValue.indexOf("="); + if (separator <= 0) return null; + return { + name: nameValue.slice(0, separator).trim(), + value: nameValue.slice(separator + 1).trim(), + domain: new URL(origin).hostname, + path: "/", + httpOnly: /;\s*httponly\b/i.test(setCookie), + sameSite: "Lax", + }; +} + +async function forceTestAccountLogin(context) { + const response = await context.request.fetch(`${BASE_URL}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: TEST_EMAIL, + password: TEST_PASSWORD, + flow: "signIn", + }, + }, + }, + headers: { "content-type": "application/json" }, + timeout: UI_TIMEOUT_MS, + }); + const payload = await response.json().catch(async () => ({ raw: await response.text() })); + assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`); + const cookies = response + .headersArray() + .filter((header) => header.name.toLowerCase() === "set-cookie") + .map((header) => parseSetCookie(header.value, BASE_URL)) + .filter(Boolean); + assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie"); + await context.addCookies(cookies); +} + +async function waitForRuntimeEditor(page) { + const editor = page + .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]') + .first(); + await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + return editor; +} + +async function waitForUploadResponse(page, fileName, action) { + const response = await page.waitForResponse( + async (candidate) => { + if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") { + return false; + } + const payload = await candidate.json().catch(() => null); + return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName)); + }, + { timeout: UI_TIMEOUT_MS }, + ); + const payload = await response.json(); + assert(response.ok(), `${action} 上传失败:${response.status()} ${JSON.stringify(payload)}`); + assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id:${JSON.stringify(payload)}`); + return payload.asset; +} + +async function waitForAssetRow(page, assetId, action) { + try { + await page.waitForFunction( + (targetAssetId) => + Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some( + (row) => + row instanceof HTMLElement && + window.getComputedStyle(row).display !== "none" && + window.getComputedStyle(row).visibility !== "hidden" && + row.getClientRects().length > 0, + ), + assetId, + { timeout: UI_TIMEOUT_MS }, + ); + } catch (error) { + const debug = await page.evaluate((targetAssetId) => ({ + mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"), + lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"), + targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length, + assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({ + id: row.getAttribute("data-asset-id"), + visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0, + text: row.textContent, + })), + body: document.body.innerText.slice(0, 1200), + }), assetId).catch((debugError) => ({ debugError: String(debugError) })); + throw new Error(`${action} 上传后的文件树附件行不可见:${assetId} ${JSON.stringify(debug)}`); + } + const title = await page.evaluate((targetAssetId) => { + const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find( + (candidate) => + candidate instanceof HTMLElement && + window.getComputedStyle(candidate).display !== "none" && + window.getComputedStyle(candidate).visibility !== "hidden" && + candidate.getClientRects().length > 0, + ); + return row?.querySelector(".tree-link-title")?.textContent?.trim() || ""; + }, assetId); + assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`); +} + +async function waitForEditorOfficeAttachment(page, assetId, fileName, action) { + const attachment = page + .locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`) + .first(); + await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const text = (await attachment.innerText()).trim(); + assert(text.includes(fileName), `${action} 正文附件标题不正确:${text}`); + const href = await attachment.getAttribute("href"); + assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice,实际:${href}`); + assert( + href && href.startsWith("/onlyoffice?"), + `${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接,实际:${href}`, + ); + assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId:${href}`); + return attachment; +} + +async function assertAttachmentActions(page, attachment, action) { + await attachment.hover({ timeout: UI_TIMEOUT_MS }); + const actions = page.locator('[data-testid="mnote-attachment-actions"]').first(); + await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS }); + const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first(); + await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const text = await menu.innerText(); + for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) { + assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”:${text}`); + } + await page.keyboard.press("Escape").catch(() => undefined); +} + +async function screenshot(page, name) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, `${name}.png`), + fullPage: false, + timeout: UI_TIMEOUT_MS, + }); +} + +async function uploadViaSlash(page, itemTestId, filePayload, action) { + const editor = await waitForRuntimeEditor(page); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.type("/"); + const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first(); + await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const slashText = await slash.innerText(); + assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组:${slashText}`); + + const item = page.locator(`[data-testid="${itemTestId}"]`).first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS }); + await screenshot(page, `slash-${itemTestId}`); + + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), + item.click({ timeout: UI_TIMEOUT_MS }), + ]); + const uploadResponse = waitForUploadResponse(page, filePayload.name, action); + await fileChooser.setFiles(filePayload); + const asset = await uploadResponse; + await waitForAssetRow(page, asset.id, action); + return asset; +} + +async function dispatchFileDrop(page, selector, filePayload, action) { + const uploadPromise = waitForUploadResponse(page, filePayload.name, action); + await page.evaluate( + ({ selector, fileName, mimeType, bytes }) => { + const target = Array.from(document.querySelectorAll(selector)).find( + (candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0, + ) || document.querySelector(selector); + if (!(target instanceof HTMLElement)) { + throw new Error(`拖放目标不存在:${selector}`); + } + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType })); + target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer })); + target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer })); + }, + { + selector, + fileName: filePayload.name, + mimeType: filePayload.mimeType, + bytes: Array.from(filePayload.buffer), + }, + ); + const asset = await uploadPromise; + await waitForAssetRow(page, asset.id, action); + return asset; +} + +async function main() { + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const networkNotes = []; + page.on("response", async (response) => { + if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return; + const text = await response.text().catch(() => ""); + networkNotes.push({ + status: response.status(), + url: response.url(), + body: text.slice(0, 1000), + }); + }); + page.on("pageerror", (error) => { + networkNotes.push({ type: "pageerror", message: error.message }); + }); + page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) { + networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) }); + } + }); + page.on("dialog", async (dialog) => { + networkNotes.push({ type: "dialog", message: dialog.message() }); + await dialog.dismiss().catch(() => undefined); + }); + let target = null; + + try { + await forceTestAccountLogin(context); + await ensureAuthenticated(page, context.request); + target = await createTempDocument(context.request, null); + await openDocument(page, target.workspaceId, target.documentId); + await openFilesystemView(page); + + const slashAttachment = ensureProbeDocx(); + const slashImage = { + name: "task175-slash-image.png", + mimeType: PNG_MIME, + buffer: tinyPngBuffer(), + }; + const treeDropAttachment = { + name: "task175-filetree-drop.docx", + mimeType: DOCX_MIME, + buffer: slashAttachment.buffer, + }; + const editorDropImage = { + name: "task175-editor-drop.png", + mimeType: PNG_MIME, + buffer: tinyPngBuffer(), + }; + + const attachmentAsset = await uploadViaSlash( + page, + "slash-item-upload-attachment", + slashAttachment, + "slash 上传附件", + ); + const editorAttachment = await waitForEditorOfficeAttachment( + page, + attachmentAsset.id, + slashAttachment.name, + "slash 上传附件", + ); + await assertAttachmentActions(page, editorAttachment, "slash 上传附件"); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForRuntimeEditor(page); + await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件"); + await openFilesystemView(page); + const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片"); + + const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`; + const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件"); + + const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]'; + const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片"); + await page.waitForFunction( + (fileName) => { + const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]")); + return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName); + }, + editorDropImage.name, + { timeout: UI_TIMEOUT_MS }, + ); + + console.log( + JSON.stringify( + { + ok: true, + workspaceId: target.workspaceId, + documentId: target.documentId, + uploadedAssetIds: [ + attachmentAsset.id, + imageAsset.id, + droppedAttachment.id, + droppedImage.id, + ], + screenshotDir: SCREENSHOT_DIR, + }, + null, + 2, + ), + ); + } catch (error) { + if (networkNotes.length) { + console.error(`上传入口调试信息:${JSON.stringify(networkNotes.slice(-20), null, 2)}`); + } + throw error; + } finally { + if (target?.documentId) { + await purgeDocument(context.request, target.documentId).catch((error) => { + console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`); + }); + } + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/wolai-frontend/src/app/onlyoffice/page.tsx b/wolai-frontend/src/app/onlyoffice/page.tsx index e8589cab..ad75d35e 100644 --- a/wolai-frontend/src/app/onlyoffice/page.tsx +++ b/wolai-frontend/src/app/onlyoffice/page.tsx @@ -1,5 +1,4 @@ import { Suspense } from "react"; -import Script from "next/script"; import OnlyOfficeClientPage from "./OnlyOfficeClientPage"; export const dynamic = "force-dynamic"; @@ -105,9 +104,8 @@ export default function OnlyOfficePage() {
} > -