fix local folder upload intent contract
This commit is contained in:
@@ -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 / 持久化尚未实现;当前只落地默认隐藏。
|
||||
|
||||
+6
-5
@@ -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`。
|
||||
|
||||
@@ -211,6 +211,7 @@ struct LocalAssetUploadFields {
|
||||
root_uri: String,
|
||||
document_id: String,
|
||||
target_relative_path: Option<String>,
|
||||
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<String> = 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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user