From ae6bbc965612e681dc0c2f233efc3200eef72cb1 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sun, 24 May 2026 02:34:52 +0800 Subject: [PATCH] fix local folder upload intent contract --- ...ree-drag-upload-target-parent-folder-v1.md | 1 + ...markdown-resource-lifecycle-contract-v1.md | 11 +-- .../src/routes/local_folder_source.rs | 79 ++++++++++++++----- rust/crates/mnote-web/src/ssr/pages/layout.rs | 29 +++++-- ...older-markdown-resource-lifecycle-smoke.js | 45 +++++++++++ 5 files changed, 136 insertions(+), 29 deletions(-) diff --git a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md index 1dd774c2..b8aa1883 100644 --- a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md +++ b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md @@ -318,6 +318,7 @@ PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folde 仍在 process: +- 2.1 上传 intent 已显式化:前端主编辑区上传发送 `editor.markdown.attach`,File Tree folder drop 发送 `filetree.folder.drop`;后端 `/api/local-folder/assets/upload` 解析并校验 intent,不再靠 `documentId` / `targetRelativePath` 隐式分流;`task479` 已断言两个入口的响应字段。 - 2.3 Backspace / Delete 删除相邻附件已补浏览器级 smoke:`task479` Check 7 通过。Backspace 与手柄删除都只删除 Markdown 链接,不删除真实附件文件,相邻附件刷新后仍保持可点击附件块。 - 2.4 切回“我的空间”已按当前 local-first MVP 语义修复并复测:旧“云空间”入口不再进入 `convex_workspace`,改为“我的空间”,通过 `mnoteHome=1` 回默认 `my-space`,避免最近普通本地目录和旧 Convex compat 接管;`task441-local-folder-cloud-switch-smoke.js` 通过。 - 页面设置中“隐藏本地 Markdown 文件标题”的可切换 UI / 持久化尚未实现;当前只落地默认隐藏。 diff --git a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md b/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md index 315cb168..b45c690e 100644 --- a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md +++ b/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md @@ -109,11 +109,11 @@ Markdown 正文链接只是引用,不是所有权声明。 ### 5.2 上传链路 -- [ ] 前端上传 detail 增加明确 `uploadIntent`。 -- [ ] 后端 `/api/local-folder/assets/upload` 解析并校验 intent。 -- [ ] `editor.markdown.attach` 写入 `markdown_page_resource_directory()`。 -- [ ] `filetree.folder.drop` 写入 `targetRelativePath` 指向的目录。 -- [ ] 上传响应返回 intent、rootRelativePath、markdownRelativePath 和 ownerDocumentId,供 smoke 断言。 +- [x] 前端上传 detail 增加明确 `uploadIntent`。 +- [x] 后端 `/api/local-folder/assets/upload` 解析并校验 intent。 +- [x] `editor.markdown.attach` 写入 `markdown_page_resource_directory()`。 +- [x] `filetree.folder.drop` 写入 `targetRelativePath` 指向的目录。 +- [x] 上传响应返回 intent、rootRelativePath、markdownRelativePath 和 ownerDocumentId,供 smoke 断言。 ### 5.3 删除和缺失资源 @@ -186,3 +186,4 @@ codegraph sync . - 验证:`task479` 全部通过,`task459-local-markdown-attachment-tab-smoke.js` 通过;Rust `root_entry_`、`local_markdown`、`document_shell_renders` 相关单测通过。 - 2026-05-24 追加:`task479` 新增 Check 7,覆盖 Backspace 与手柄删除单个附件引用;删除后只改 Markdown 链接,不删除真实附件文件,相邻附件刷新后仍保持可点击附件块。 - 2026-05-24 追加:来源菜单的旧“云空间”入口按当前 local-first MVP 语义改为“我的空间”,默认回到受管 `my-space` 本地根;新增 `mnoteHome=1` 防止被最近普通本地目录自动重定向接管。`task441-local-folder-cloud-switch-smoke.js` 已按当前语义通过。 +- 2026-05-24 追加:上传链路已改为显式 `uploadIntent` 合同。主编辑区上传发送并返回 `editor.markdown.attach`,写入 page resource directory;File Tree folder drop 发送并返回 `filetree.folder.drop`,写入 `targetRelativePath` 指向目录。`task479` 已断言响应中的 `uploadIntent`、`rootRelativePath`、`markdownRelativePath`、`ownerDocumentId`。 diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index c6326d7e..df1ca312 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -211,6 +211,7 @@ struct LocalAssetUploadFields { root_uri: String, document_id: String, target_relative_path: Option, + upload_intent: String, kind: String, } @@ -3077,20 +3078,41 @@ pub async fn upload_local_markdown_asset( let fields = read_local_asset_upload_multipart(multipart).await?; ensure_local_workspace_write_access_with_state(&state, &context, &fields.root_uri) .map_err(|error| error.with_context(&context))?; - let asset = if fields.target_relative_path.is_some() || fields.document_id.trim().is_empty() { - write_local_folder_file_upload( - &fields.root_uri, - fields.target_relative_path.as_deref().unwrap_or(""), - &fields.kind, - fields.file, - )? - } else { - write_local_markdown_asset( - &fields.root_uri, - &fields.document_id, - &fields.kind, - fields.file, - )? + let asset = match fields.upload_intent.as_str() { + "editor.markdown.attach" => { + if fields.document_id.trim().is_empty() || fields.target_relative_path.is_some() { + return Err(WebError::bad_request_code( + "local_asset_upload_intent_invalid", + "editor.markdown.attach 必须提供 documentId 且不能提供 targetRelativePath", + )); + } + write_local_markdown_asset( + &fields.root_uri, + &fields.document_id, + &fields.kind, + fields.file, + )? + } + "filetree.folder.drop" => { + let Some(target_relative_path) = fields.target_relative_path.as_deref() else { + return Err(WebError::bad_request_code( + "local_asset_upload_intent_invalid", + "filetree.folder.drop 必须提供 targetRelativePath", + )); + }; + write_local_folder_file_upload( + &fields.root_uri, + target_relative_path, + &fields.kind, + fields.file, + )? + } + _ => { + return Err(WebError::bad_request_code( + "local_asset_upload_intent_unsupported", + "不支持的本地上传 intent", + )); + } }; Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset })))) } @@ -3361,6 +3383,7 @@ async fn read_local_asset_upload_multipart( let mut root_uri = String::new(); let mut document_id = String::new(); let mut target_relative_path: Option = None; + let mut upload_intent = String::new(); let mut kind = String::new(); while let Some(field) = multipart.next_field().await.map_err(|error| { @@ -3413,6 +3436,7 @@ async fn read_local_asset_upload_multipart( "targetRelativePath" | "targetDirectoryPath" => { target_relative_path = Some(value.trim().to_string()) } + "uploadIntent" | "upload_intent" => upload_intent = value.trim().to_string(), "kind" => kind = value.trim().to_string(), _ => {} } @@ -3421,10 +3445,7 @@ async fn read_local_asset_upload_multipart( let file = file.ok_or_else(|| { WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file") })?; - if file.bytes.is_empty() - || root_uri.is_empty() - || (document_id.is_empty() && target_relative_path.is_none()) - { + if file.bytes.is_empty() || root_uri.is_empty() || upload_intent.is_empty() { return Err(WebError::bad_request_code( "local_asset_upload_required_missing", "缺少必要参数", @@ -3435,6 +3456,7 @@ async fn read_local_asset_upload_multipart( root_uri, document_id, target_relative_path, + upload_intent, kind, }) } @@ -3501,6 +3523,7 @@ pub(crate) fn write_local_folder_file_upload( let asset_type = local_upload_asset_type(kind, &file.content_type); Ok(json!({ "id": format!("local-file:{root_relative_path}"), + "uploadIntent": "filetree.folder.drop", "asset_type": asset_type, "file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name), "mime_type": file.content_type, @@ -3510,6 +3533,9 @@ pub(crate) fn write_local_folder_file_upload( "sourceKind": "local_folder", "rootUri": file_uri_for_path(&canonical_root), "rootRelativePath": root_relative_path, + "targetRelativePath": target_relative_path, + "markdownRelativePath": Value::Null, + "ownerDocumentId": Value::Null, })) } @@ -3610,6 +3636,7 @@ pub(crate) fn write_local_markdown_asset( write_uploaded_asset_index_metadata(&canonical_root, &uploaded_assets)?; Ok(json!({ "id": format!("local:asset:{root_relative_path}"), + "uploadIntent": "editor.markdown.attach", "asset_type": asset_type, "file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name), "mime_type": file.content_type, @@ -3618,9 +3645,11 @@ pub(crate) fn write_local_markdown_asset( "sourcePath": markdown_relative_path, "document_id": document_id, "documentId": document_id, + "ownerDocumentId": document_id, "sourceKind": "local_folder", "rootUri": file_uri_for_path(&canonical_root), "rootRelativePath": root_relative_path, + "markdownRelativePath": markdown_relative_path, })) } @@ -11509,6 +11538,13 @@ fn main() {} assert_eq!(asset["file_url"], "photo-1.png"); assert_eq!(asset["asset_type"], "image"); assert_eq!(asset["sourceKind"], "local_folder"); + assert_eq!(asset["uploadIntent"], "editor.markdown.attach"); + assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png"); + assert_eq!(asset["markdownRelativePath"], "photo-1.png"); + assert_eq!( + asset["ownerDocumentId"], + "local-md:docs~2FREADME~2FREADME.md" + ); assert_eq!( std::fs::read(root.join("docs").join("README").join("photo-1.png")) .expect("read copied asset"), @@ -11526,6 +11562,13 @@ fn main() {} ) .expect("upload markdown asset"); assert_eq!(markdown_asset["sourcePath"], "notes.md"); + assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach"); + assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md"); + assert_eq!(markdown_asset["markdownRelativePath"], "notes.md"); + assert_eq!( + markdown_asset["ownerDocumentId"], + "local-md:docs~2FREADME~2FREADME.md" + ); let uploaded_asset_index = std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json")) .expect("uploaded asset index"); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index a1045fe9..ffb73cc1 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3073,7 +3073,8 @@ const SIDEBAR_TREE_JS: &str = r##" targetDocumentId: documentId, targetMindmapId: null, targetSubPath: null, - targetRelativePath: String(detail.targetRelativePath || '') + targetRelativePath: String(detail.targetRelativePath || ''), + uploadIntent: String(detail.uploadIntent || 'filetree.folder.drop') }; } if (!workspaceId || !documentId) { @@ -3083,7 +3084,8 @@ const SIDEBAR_TREE_JS: &str = r##" workspaceId: workspaceId, targetDocumentId: documentId, targetMindmapId: null, - targetSubPath: null + targetSubPath: null, + uploadIntent: String(detail && detail.uploadIntent || 'editor.markdown.attach') }; } @@ -3096,6 +3098,13 @@ const SIDEBAR_TREE_JS: &str = r##" if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) { plan.targetRelativePath = String(detail.targetRelativePath || ''); } + if (detail && detail.uploadIntent) { + plan.uploadIntent = String(detail.uploadIntent); + } else if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) { + plan.uploadIntent = 'filetree.folder.drop'; + } else if (!plan.uploadIntent) { + plan.uploadIntent = 'editor.markdown.attach'; + } return plan; } catch (error) { console.warn('[mnote upload] upload target preflight fallback', error); @@ -3878,12 +3887,14 @@ const SIDEBAR_TREE_JS: &str = r##" var rootUri = currentRootUri(); var documentId = String(plan && plan.targetDocumentId || currentDocumentId() || '').trim(); var hasFolderTarget = plan && Object.prototype.hasOwnProperty.call(plan, 'targetRelativePath'); - if (!rootUri || (!documentId && !hasFolderTarget)) { + var uploadIntent = String(plan && plan.uploadIntent || (hasFolderTarget ? 'filetree.folder.drop' : 'editor.markdown.attach')).trim(); + if (!rootUri || !uploadIntent) { throw new Error('本地 Markdown 上传缺少 rootUri 或 documentId'); } var localForm = new FormData(); localForm.append('file', file); localForm.append('rootUri', rootUri); + localForm.append('uploadIntent', uploadIntent); if (documentId) localForm.append('documentId', documentId); if (hasFolderTarget) localForm.append('targetRelativePath', String(plan.targetRelativePath || '')); localForm.append('kind', file && String(file.type || '').indexOf('image/') === 0 ? 'image' : 'attachment'); @@ -3969,7 +3980,8 @@ const SIDEBAR_TREE_JS: &str = r##" void uploadFilesWithResolvedTarget(files, { workspaceId: uploadContext.workspaceId || resolveWorkspaceId(document.body), documentId: uploadContext.documentId || currentDocumentId(), - targetRowId: null + targetRowId: null, + uploadIntent: 'editor.markdown.attach' }, { insertIntoEditor: detail && detail.insertIntoEditor !== false, editorRoot: uploadContext.root @@ -4007,7 +4019,8 @@ const SIDEBAR_TREE_JS: &str = r##" void uploadFilesWithResolvedTarget(files, { workspaceId: resolveWorkspaceId(document.body), documentId: currentDocumentId(), - targetRowId: null + targetRowId: null, + uploadIntent: 'editor.markdown.attach' }, { insertIntoEditor: true, editorRoot: editorUploadRootFromElement(editorTarget) @@ -10026,7 +10039,8 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null, targetRelativePath: targetRow && (targetRow.getAttribute('data-row-kind') === 'folder' || targetRow.getAttribute('data-row-kind') === 'directory') ? fileTreeRowLocalRelativePath(targetRow) - : null + : null, + uploadIntent: 'filetree.folder.drop' }; if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false'); activeFileTreeDropRow = null; @@ -10871,6 +10885,9 @@ mod tests { "主编辑器上传应能从当前文档 DOM 回退解析 documentId" ); assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)")); + assert!(SIDEBAR_TREE_JS.contains("localForm.append('uploadIntent', uploadIntent)")); + assert!(SIDEBAR_TREE_JS.contains("uploadIntent: 'editor.markdown.attach'")); + assert!(SIDEBAR_TREE_JS.contains("uploadIntent: 'filetree.folder.drop'")); assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)")); assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)")); assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl")); diff --git a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js index 19ac30f8..9f9de14c 100644 --- a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js +++ b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js @@ -109,6 +109,20 @@ async function waitForFileExists(filePath, timeoutMs) { return fs.existsSync(filePath); } +async function waitForUploadAsset(uploadResponses, startIndex, predicate, timeoutMs) { + const startedAt = Date.now(); + let seen = []; + while (Date.now() - startedAt < timeoutMs) { + seen = uploadResponses.slice(startIndex); + for (const entry of seen) { + const asset = entry?.payload?.asset; + if (asset && predicate(asset, entry)) return asset; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`未捕获到符合条件的上传响应: ${JSON.stringify(seen).slice(0, 1000)}`); +} + async function waitForFileContent(filePath, predicate, timeoutMs) { const startedAt = Date.now(); let lastContent = ""; @@ -257,6 +271,12 @@ async function main() { const page = await context.newPage(); const popups = []; page.on("popup", (popup) => popups.push(popup)); + const uploadResponses = []; + page.on("response", async (response) => { + if (!response.url().includes("/api/local-folder/assets/upload")) return; + const payload = await response.json().catch(() => null); + uploadResponses.push({ status: response.status(), payload }); + }); const screenshots = []; const checks = {}; @@ -282,8 +302,15 @@ async function main() { timeout: UI_TIMEOUT_MS, }); + const uploadStart = uploadResponses.length; // 通过主编辑区斜杠上传上传一个 MD 文件 await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n"); + const uploadAsset = await waitForUploadAsset( + uploadResponses, + uploadStart, + (asset) => asset.file_name === "uploaded-one.md", + UI_TIMEOUT_MS, + ); // 等待后端 write_local_markdown_asset() 落盘 const correctPath = path.join(resourceDir, "uploaded-one.md"); @@ -296,12 +323,17 @@ async function main() { `上传文件应落在 page resource directory (${correctPath})。` + ` landedInRoot=${landedInRoot} correctPath=${correctPath}`, ); + assert.equal(uploadAsset.uploadIntent, "editor.markdown.attach", "主编辑区上传响应应返回 editor.markdown.attach intent"); + assert.equal(uploadAsset.rootRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 page resource rootRelativePath"); + assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath"); + assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId"); checks[checkId] = { ok: true, message: `上传文件落在资源目录 ${correctPath}`, landedInResourceDir, landedInRoot, + uploadAsset, }; } catch (err) { overallOk = false; @@ -332,6 +364,7 @@ async function main() { const droppedFileName = "drag-dropped-note.md"; const droppedContent = "# Drag Dropped\n\n从外部拖入\n"; + const uploadStart = uploadResponses.length; // 使用 dispatchEvent 模拟外部文件拖入 docs 文件夹 await docsFolderRow.dispatchEvent("dragover", { @@ -358,7 +391,18 @@ async function main() { // 等待文件落盘 const droppedPath = path.join(root, "docs", droppedFileName); const exists = await waitForFileExists(droppedPath, UI_TIMEOUT_MS); + const uploadAsset = await waitForUploadAsset( + uploadResponses, + uploadStart, + (asset) => asset.file_name === droppedFileName, + UI_TIMEOUT_MS, + ); assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`); + assert.equal(uploadAsset.uploadIntent, "filetree.folder.drop", "文件树 folder drop 响应应返回 filetree.folder.drop intent"); + assert.equal(uploadAsset.rootRelativePath, "docs/drag-dropped-note.md", "文件树 folder drop 响应应返回目标目录 rootRelativePath"); + assert.equal(uploadAsset.targetRelativePath, "docs", "文件树 folder drop 响应应返回 targetRelativePath"); + assert.equal(uploadAsset.markdownRelativePath, null, "文件树 folder drop 不应返回 markdownRelativePath"); + assert.equal(uploadAsset.ownerDocumentId, null, "文件树 folder drop 不应返回 ownerDocumentId"); // 确保没有出现在 root 或 README/ 下 const wrongPath = path.join(root, droppedFileName); @@ -373,6 +417,7 @@ async function main() { message: `外部拖入文件进入目标文件夹 docs/`, droppedPath, exists, + uploadAsset, }; } catch (err) { overallOk = false;