From 1b5d6a2a2d515b595bcf0ddccd7614e3893024ad Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 19 May 2026 09:38:57 +0800 Subject: [PATCH] feat: advance local-first conflict and search indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补齐本地 markdown 冲突处理与合并写回路径\n- 增加本地搜索索引路由、刷新与 browser smoke\n- 同步更新 current-priority checklist 的阶段进度 --- ...current-priority-execution-checklist-v1.md | 67 +- rust/crates/mnote-web/src/error.rs | 11 + rust/crates/mnote-web/src/routes/documents.rs | 26 + .../src/routes/local_folder_source.rs | 147 ++++- .../src/routes/local_search_index.rs | 615 ++++++++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 13 + rust/crates/mnote-web/src/routes/search.rs | 432 +++++++++++- rust/crates/mnote-web/src/routes/web_shell.rs | 104 ++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 29 +- rust/crates/mnote-web/src/ssr/styles.rs | 14 + ...l-markdown-conflict-resolution-ui-smoke.js | 55 ++ ...ask452-local-search-index-browser-smoke.js | 158 +++++ 12 files changed, 1622 insertions(+), 49 deletions(-) create mode 100644 rust/crates/mnote-web/src/routes/local_search_index.rs create mode 100644 scripts/task452-local-search-index-browser-smoke.js diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index 8fab6f7f..beafd41b 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -88,7 +88,8 @@ - 结论:本地 markdown aggregate 已暴露 `fileVersion` alias;`documents/save` compat 会把 `expectedFileVersion` / `conflictDetectionKey` 收敛到 `PageBodyWriteRequest.expected_file_version`;`/api/page-body/write` 只接受 `expectedFileVersion`,避免 alias 重复。 - [x] 确认所有本地 markdown 写入口都携带 expected file version:tiptap 保存、`mnote.doc.markdown_edit`、`mnote.page.save`、documents compat save。 - 实现:tiptap local-folder 保存改走 `/api/page-body/write` 并只传 `expectedFileVersion`;`mnote.doc.markdown_edit` 从当前 aggregate 的 `fileVersion` / `conflictDetectionKey` 取 expected version;`mnote.page.save` 读取 tool 入参 `expectedFileVersion`;documents compat save 继续兼容旧 `conflictDetectionKey`。 -- [ ] 统一冲突错误 envelope:错误码、当前磁盘版本、编辑器基线版本、documentId、rootUri、建议动作。 +- [x] 统一冲突错误 envelope:错误码、当前磁盘版本、编辑器基线版本、documentId、rootUri、建议动作。 + - 实现:本地 markdown stale 写入返回 `details.conflict`,包含 `code`、`currentDiskVersion`、`editorBaseVersion`、`documentId`、`rootUri`、`suggestedActions`。 - [x] 让冲突错误不丢失当前编辑器内容,前端可以继续保留未保存 buffer。 - 实现:冲突态优先从当前挂载的 ProseMirror DOM 捕获文本;“保留当前编辑器版本”会用最新 `fileVersion` 重新提交当前 buffer。 @@ -99,9 +100,12 @@ - [x] 实现“接受磁盘版本”:重新读取文件,替换编辑器 buffer。 - [x] 实现“保留编辑器版本”:用最新 fileVersion 重新提交当前编辑器内容。 - [x] 实现“打开 diff”:支持 markdown 文本并排查看;复杂块结构先降级为 markdown 文本。 -- [ ] 实现“合并”:支持从 diff 面板选择合并结果并写回。 -- [ ] 合并完成后写回本地 markdown,并刷新 page aggregate / file tree snapshot。 -- [ ] AI 写入导致冲突时,提示来源为 agent run,而不是普通外部修改。 +- [x] 实现“合并”:支持从 diff 面板选择合并结果并写回。 + - 实现:冲突 diff 面板新增合并 textarea,可选择当前版本 / 磁盘版本作为合并基础,并写回合并结果。 +- [x] 合并完成后写回本地 markdown,并刷新 page aggregate / file tree snapshot。 + - 验证:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js` 覆盖合并写回,文件内容包含当前编辑器内容、磁盘内容和新增合并内容。 +- [x] AI 写入导致冲突时,提示来源为 agent run,而不是普通外部修改。 + - 实现:页面收到 `mnote:page-ai-tool-write-completed` / `agent.changed_files` 后记录 agent run 来源;冲突面板来源显示为 `agent run `。 建议验证: @@ -109,7 +113,8 @@ - 验证:`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture` - [x] 新增 browser smoke:浏览器打开页面后外部修改同一 `.md` 文件,保存时出现冲突 UI。 - 验证:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js` -- [ ] 新增 browser smoke:agent 修改同一 `.md` 文件后,tiptap 保存触发冲突 UI。 +- [x] 新增 browser smoke:agent 修改同一 `.md` 文件后,tiptap 保存触发冲突 UI。 + - 验证:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js` 覆盖 agent 修改同一 `.md` 后的冲突 UI,并断言面板出现 `agent run `。 补充验证: @@ -173,31 +178,53 @@ ### 4.1 索引边界 -- [ ] 设计本地索引目录:建议放在 workspace `.mnote/index/` 或 `/mnt/Data1T/Mnote_data/control-plane/index/`,二者职责需明确。 -- [ ] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。 +- [x] 设计本地索引目录:建议放在 workspace `.mnote/index/` 或 `/mnt/Data1T/Mnote_data/control-plane/index/`,二者职责需明确。 + - 实现:单 workspace 本地索引落在授权 root 的 `.mnote/index/search-index.json`;control-plane index 只保留给后续跨 workspace / shared cache 总索引。 +- [x] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。 + - 实现:`/api/search/documents` 的 `local_folder` 分支先走 `ensure_local_workspace_read_access`,再对 canonical root 内 `.md` 文件重建索引。 - [ ] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource metadata。 -- [ ] 定义索引输出:全文 search、backlinks、resource refs、tags、recent changes。 + - 已完成最小输入:markdown 正文、frontmatter `title/tags/mnote_id`、markdown 链接、双链、附件 / mindmap / office 文件引用;待补 mindmap / office 原生 metadata 索引。 +- [x] 定义索引输出:全文 search、backlinks、resource refs、tags、recent changes。 + - 实现:本地索引输出 `results` 与 `recentChanges`,结果包含全文命中、`backlinks`、`resourceRefs`、`tags`、`path`、`resourceType`。 ### 4.2 索引更新 - [ ] 本地文件 watcher 事件触发增量索引。 -- [ ] 手动 refresh / resync 触发 root 全量索引。 -- [ ] 文件移动、重命名、删除、恢复后同步更新索引。 -- [ ] AI 写入和 tiptap 保存后更新索引。 -- [ ] 索引损坏时可重建,不影响正文文件。 +- [x] 手动 refresh / resync 触发 root 全量索引。 + - 实现:`POST /api/search/local-index/refresh` 会在授权 root 内全量重建 `.mnote/index/search-index.json`。 + - 验证:`cargo test -p mnote-web search_local_index_refresh_rebuilds_authorized_root -- --nocapture` +- [x] 文件移动、重命名、删除、恢复后同步更新索引。 + - 实现:本地 `tree command` 成功后统一 best-effort 刷新 `.mnote/index/search-index.json`;验证覆盖 rename 后索引路径更新。 + - 验证:`cargo test -p mnote-web local_tree_command_refreshes_search_index_after_rename -- --nocapture` +- [x] AI 写入和 tiptap 保存后更新索引。 + - 实现:`save_local_markdown_page` 成功写回后统一 best-effort 刷新本地搜索索引,覆盖 tiptap 保存与 MNote tool 写入兼容链。 + - 验证:`cargo test -p mnote-web local_markdown_save_refreshes_search_index_after_write -- --nocapture` +- [x] 索引损坏时可重建,不影响正文文件。 + - 实现:本地搜索每次从授权 root 重新生成 `.mnote/index/search-index.json`,不会读取索引作为正文真相。 ### 4.3 搜索体验 -- [ ] 全局搜索优先搜索当前 workspace 本地索引。 -- [ ] 搜索结果显示文件路径、标题、命中片段和资源类型。 +- [x] 全局搜索优先搜索当前 workspace 本地索引。 + - 实现:页面搜索请求携带 `sourceKind/rootUri`;`local_folder` 请求直接查本地索引,不先走 Convex runtime。 +- [x] 搜索结果显示文件路径、标题、命中片段和资源类型。 + - 实现:搜索结果行显示标题、命中片段、路径和 `resourceType` 标识;API 同步返回 `path/title/snippet/resourceType`。 - [ ] 反链面板读取本地索引,不再依赖云端搜索。 + - 已完成数据源:`GET /api/search/local-index/backlinks` 从授权 root 本地索引读取反链;待挂接真实反链面板 UI。 - [ ] 标签列表读取本地索引。 + - 已完成数据源:`GET /api/search/local-index/tags` 从授权 root 本地索引读取标签聚合;待挂接真实标签列表 UI。 建议验证: -- [ ] 单测:markdown 链接和双链可生成 backlinks。 -- [ ] 单测:附件 / mindmap / office 引用可生成 resource refs。 -- [ ] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。 +- [x] 单测:markdown 链接和双链可生成 backlinks。 + - 验证:`cargo test -p mnote-web local_search_index -- --nocapture` +- [x] 单测:附件 / mindmap / office 引用可生成 resource refs。 + - 验证:`cargo test -p mnote-web local_search_index -- --nocapture` +- [x] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。 + - 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task452-local-search-index-browser-smoke.js` + +补充验证: + +- [x] `cargo test -p mnote-web search_local_index_backlinks_and_tags_read_authorized_root -- --nocapture` --- @@ -358,8 +385,10 @@ 本文件迁入 `done/` 前必须满足: -- [ ] P0 管理员目录授权 UI / API 完成并有 browser smoke。 -- [ ] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。 +- [x] P0 管理员目录授权 UI / API 完成并有 browser smoke。 + - 证据:`node scripts/task450-admin-access-policy-ui-smoke.js`;`cargo test -p mnote-web admin_access_policy -- --nocapture`。 +- [x] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。 + - 证据:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`;`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`。 - [ ] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。 - [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。 - [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。 diff --git a/rust/crates/mnote-web/src/error.rs b/rust/crates/mnote-web/src/error.rs index e7c7566a..9b7f67cd 100644 --- a/rust/crates/mnote-web/src/error.rs +++ b/rust/crates/mnote-web/src/error.rs @@ -4,6 +4,7 @@ use axum::http::{HeaderName, HeaderValue}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Serialize; +use serde_json::Value; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -13,6 +14,8 @@ pub struct ErrorBody { pub message: String, pub request_id: Option, pub trace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, } #[derive(Debug, Clone)] @@ -22,6 +25,7 @@ pub struct WebError { message: String, request_context: Option, headers: Vec<(&'static str, String)>, + details: Option, } impl WebError { @@ -32,6 +36,7 @@ impl WebError { message: message.into(), request_context: None, headers: Vec::new(), + details: None, } } @@ -69,6 +74,11 @@ impl WebError { self } + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + pub fn message(&self) -> &str { &self.message } @@ -96,6 +106,7 @@ impl IntoResponse for WebError { .request_context .as_ref() .map(|context| context.trace.trace_id.clone()), + details: self.details, }; let mut response = (self.status, Json(body)).into_response(); if let Ok(name) = HeaderName::from_lowercase(b"x-error-code") { diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index c40bb321..13925b87 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -1558,6 +1558,32 @@ mod tests { .expect("save response"); assert_eq!(response.status(), StatusCode::CONFLICT); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["code"], "local_markdown_external_change"); + assert_eq!( + payload["details"]["conflict"]["documentId"].as_str(), + Some(document_id) + ); + assert_eq!( + payload["details"]["conflict"]["rootUri"].as_str(), + Some(root_uri.as_str()) + ); + assert_eq!( + payload["details"]["conflict"]["editorBaseVersion"].as_str(), + Some(stale_file_version.as_str()) + ); + assert!(payload["details"]["conflict"]["currentDiskVersion"] + .as_str() + .unwrap_or("") + .starts_with("local-md:local-mdid:expected-file-version:")); + assert!(payload["details"]["conflict"]["suggestedActions"] + .as_array() + .unwrap() + .iter() + .any(|action| action.as_str() == Some("merge"))); let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(markdown.contains("# External")); assert!(!markdown.contains("# Editor")); 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 e26e3eb2..eac4a75e 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -7,6 +7,7 @@ use crate::page_aggregate::{ use crate::routes::local_markdown_parser::{ file_stem_title, parse_markdown_page, split_frontmatter, }; +use crate::routes::local_search_index; use crate::routes::snapshot_support::ProjectionSnapshot; use axum::extract::{Extension, Multipart, Path as AxumPath, Query}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; @@ -1380,10 +1381,11 @@ pub fn save_local_markdown_page( .filter(|value| !value.is_empty()) { if expected_key != current_conflict_key { - return Err(WebError::new( - StatusCode::CONFLICT, - "local_markdown_external_change", - "本地 Markdown 文件已被外部修改,请刷新后再保存", + return Err(local_markdown_conflict_error( + root_uri, + document_id, + expected_key, + ¤t_conflict_key, )); } } @@ -1403,6 +1405,8 @@ pub fn save_local_markdown_page( ), ) })?; + let workspace_id = local_workspace_id(&canonical_root); + refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); let next_conflict_key = local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; Ok(json!({ @@ -1417,6 +1421,38 @@ pub fn save_local_markdown_page( })) } +fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) { + let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id); +} + +fn local_markdown_conflict_error( + root_uri: &str, + document_id: &str, + editor_base_version: &str, + current_disk_version: &str, +) -> WebError { + WebError::new( + StatusCode::CONFLICT, + "local_markdown_external_change", + "本地 Markdown 文件已被外部修改,请刷新后再保存", + ) + .with_details(json!({ + "conflict": { + "code": "local_markdown_external_change", + "documentId": document_id, + "rootUri": root_uri, + "currentDiskVersion": current_disk_version, + "editorBaseVersion": editor_base_version, + "suggestedActions": [ + "accept_disk", + "keep_editor", + "open_diff", + "merge" + ] + } + })) +} + pub fn write_local_markdown_page_body( request: &core_protocol::PageBodyWriteRequest, ) -> Result { @@ -1765,6 +1801,8 @@ pub fn update_local_markdown_title( ), ) })?; + let workspace_id = local_workspace_id(&canonical_root); + refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); Ok(json!({ "ok": true, "documentId": document_id, @@ -1814,7 +1852,7 @@ pub fn execute_local_tree_command( format!("无法访问本地文件夹: {error}"), ) })?; - match action { + let result = match action { "create" => { create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面")) } @@ -1838,7 +1876,10 @@ pub fn execute_local_tree_command( "local_tree_command_unsupported", format!("local_folder 暂不支持 tree action: {other}"), )), - } + }?; + let workspace_id = local_workspace_id(&canonical_root); + refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); + Ok(result) } fn create_local_markdown_page( @@ -4412,12 +4453,12 @@ mod tests { add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, create_local_access_grant, ensure_local_path_read_access, ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor, - get_local_access_policy, initialize_local_page_id, initialize_local_workspace_for_actor, - load_local_folder_page_tree_snapshot, local_folder_watch_revision, open_local_file, - resolve_local_markdown_page_aggregate, save_local_markdown_page, - validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body, - LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery, - LocalUploadFile, + execute_local_tree_command, get_local_access_policy, initialize_local_page_id, + initialize_local_workspace_for_actor, load_local_folder_page_tree_snapshot, + local_folder_watch_revision, open_local_file, resolve_local_markdown_page_aggregate, + save_local_markdown_page, validate_local_access_root, write_local_markdown_asset, + write_local_markdown_page_body, LocalAccessGrantRequest, LocalAccessValidateRootRequest, + LocalFileOpenQuery, LocalUploadFile, }; use crate::context::RequestContext; use axum::extract::{Extension, Path as AxumPath, Query}; @@ -5353,6 +5394,88 @@ fn main() {} let _ = std::fs::remove_dir_all(&base); } + #[test] + fn local_tree_command_refreshes_search_index_after_rename() { + let root = temp_root("mnote-local-tree-refresh-search-index"); + init_workspace(&root); + std::fs::write( + root.join("search-target.md"), + "# Search Target\nrename-token\n", + ) + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + + crate::routes::local_search_index::refresh_local_search_index( + &root, + &root_uri, + "local-ws-search-index-test", + ) + .expect("initial index"); + let before = + std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) + .expect("before index"); + assert!(before.contains("search-target.md")); + + execute_local_tree_command( + &root_uri, + "rename", + "local-md:search-target.md", + None, + Some("Renamed Target"), + ) + .expect("rename"); + + let after = + std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) + .expect("after index"); + assert!(after.contains("Renamed Target.md"), "{after}"); + assert!(!after.contains("search-target.md"), "{after}"); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_refreshes_search_index_after_write() { + let root = temp_root("mnote-local-save-refresh-search-index"); + init_workspace(&root); + std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + crate::routes::local_search_index::refresh_local_search_index( + &root, + &root_uri, + "local-ws-search-index-test", + ) + .expect("initial index"); + save_local_markdown_page( + &root_uri, + "local-md:README.md", + None, + &serde_json::json!([ + { + "id": "heading_1", + "type": "heading", + "props": { "level": 1 }, + "content": [{ "type": "text", "text": "After" }] + }, + { + "id": "p_1", + "type": "paragraph", + "content": [{ "type": "text", "text": "new-token" }] + } + ]), + ) + .expect("save markdown"); + + let index = + std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) + .expect("index"); + assert!(index.contains("new-token"), "{index}"); + assert!(!index.contains("old-token"), "{index}"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() { let root = temp_root("mnote-local-markdown-asset-upload"); diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs new file mode 100644 index 00000000..a97894a3 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -0,0 +1,615 @@ +use crate::error::WebError; +use crate::routes::local_markdown_parser::{ + parse_markdown_attachment_link, parse_markdown_page, split_frontmatter, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +const LOCAL_SEARCH_INDEX_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalSearchIndex { + version: u32, + built_at: u128, + root_uri: String, + workspace_id: String, + documents: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalSearchDocument { + document_id: String, + title: String, + path: String, + raw_text: String, + tags: Vec, + backlinks: Vec, + resource_refs: Vec, + updated_at: u128, +} + +pub(crate) fn query_local_search_index( + root_path: &Path, + root_uri: &str, + workspace_id: &str, + query: &str, + page_id: Option<&str>, + limit: u32, + title_only: bool, + exact: bool, +) -> Result { + let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; + let normalized_query = normalize_search_text(query); + let page_id = page_id.map(str::trim).filter(|value| !value.is_empty()); + let recent_changes = local_recent_changes_projection(&index.documents, root_uri); + let mut results = Vec::new(); + for document in index.documents.iter() { + if let Some(page_id) = page_id { + if document.document_id != page_id { + continue; + } + } + if !local_search_document_matches(document, &normalized_query, title_only, exact) { + continue; + } + results.push(local_search_document_projection( + document, + root_uri, + &normalized_query, + )); + if results.len() >= limit.max(1) as usize { + break; + } + } + Ok(json!({ + "enqueueAssetIds": [], + "projectionOwner": "rust-kernel", + "sourceKind": "local_folder", + "index": { + "version": index.version, + "rootUri": index.root_uri, + "workspaceId": index.workspace_id, + "builtAt": index.built_at, + "documentCount": index.documents.len() + }, + "recentChanges": recent_changes, + "results": results + })) +} + +pub(crate) fn refresh_local_search_index( + root_path: &Path, + root_uri: &str, + workspace_id: &str, +) -> Result { + let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; + Ok(json!({ + "version": index.version, + "rootUri": index.root_uri, + "workspaceId": index.workspace_id, + "builtAt": index.built_at, + "documentCount": index.documents.len() + })) +} + +pub(crate) fn query_local_backlinks( + root_path: &Path, + root_uri: &str, + workspace_id: &str, + document_id: &str, +) -> Result { + let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; + let Some(target) = index + .documents + .iter() + .find(|document| document.document_id == document_id) + else { + return Ok(json!({ + "documentId": document_id, + "rootUri": root_uri, + "workspaceId": workspace_id, + "backlinks": [] + })); + }; + let target_keys = normalized_document_targets(target); + let backlinks = index + .documents + .iter() + .filter(|document| document.document_id != target.document_id) + .filter_map(|document| { + let matched_targets = document + .backlinks + .iter() + .filter(|link| target_keys.contains(&normalize_search_text(link))) + .cloned() + .collect::>(); + if matched_targets.is_empty() { + return None; + } + Some(json!({ + "documentId": document.document_id, + "title": document.title, + "path": document.path, + "resourceType": "markdown", + "matchedTargets": matched_targets, + "snippet": search_snippet(document, &normalize_search_text(&target.title)) + })) + }) + .collect::>(); + Ok(json!({ + "documentId": target.document_id, + "title": target.title, + "path": target.path, + "rootUri": root_uri, + "workspaceId": workspace_id, + "backlinks": backlinks + })) +} + +pub(crate) fn query_local_tags( + root_path: &Path, + root_uri: &str, + workspace_id: &str, +) -> Result { + let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; + let mut tags = std::collections::BTreeMap::>::new(); + for document in &index.documents { + for tag in &document.tags { + tags.entry(tag.clone()).or_default().push(document); + } + } + Ok(json!({ + "rootUri": root_uri, + "workspaceId": workspace_id, + "tags": tags + .into_iter() + .map(|(tag, documents)| { + json!({ + "tag": tag, + "count": documents.len(), + "documents": documents + .into_iter() + .map(|document| { + json!({ + "documentId": document.document_id, + "title": document.title, + "path": document.path, + "resourceType": "markdown" + }) + }) + .collect::>() + }) + }) + .collect::>() + })) +} + +fn rebuild_local_search_index( + root_path: &Path, + root_uri: &str, + workspace_id: &str, +) -> Result { + let mut documents = Vec::new(); + collect_markdown_documents(root_path, root_path, &mut documents)?; + documents.sort_by(|left, right| left.path.cmp(&right.path)); + let index = LocalSearchIndex { + version: LOCAL_SEARCH_INDEX_VERSION, + built_at: now_ms(), + root_uri: root_uri.to_string(), + workspace_id: workspace_id.to_string(), + documents, + }; + write_local_search_index(root_path, &index)?; + Ok(index) +} + +fn collect_markdown_documents( + root_path: &Path, + current: &Path, + documents: &mut Vec, +) -> Result<(), WebError> { + let entries = match fs::read_dir(current) { + Ok(entries) => entries, + Err(error) => { + return Err(WebError::bad_request_code( + "local_search_index_read_dir_failed", + format!("无法读取本地搜索索引目录 {}: {error}", current.display()), + )); + } + }; + for entry in entries { + let entry = entry.map_err(|error| { + WebError::bad_request_code( + "local_search_index_read_entry_failed", + format!("无法读取本地搜索索引条目: {error}"), + ) + })?; + let path = entry.path(); + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if file_name == ".mnote" { + continue; + } + let file_type = entry.file_type().map_err(|error| { + WebError::bad_request_code( + "local_search_index_stat_failed", + format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()), + ) + })?; + if file_type.is_dir() { + collect_markdown_documents(root_path, &path, documents)?; + continue; + } + if !file_type.is_file() || !is_markdown_path(&path) { + continue; + } + documents.push(index_markdown_file(root_path, &path)?); + } + Ok(()) +} + +fn index_markdown_file(root_path: &Path, path: &Path) -> Result { + let markdown = fs::read_to_string(path).map_err(|error| { + WebError::bad_request_code( + "local_search_index_read_markdown_failed", + format!("无法读取本地 Markdown 索引文件 {}: {error}", path.display()), + ) + })?; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let parsed = parse_markdown_page(&markdown, file_name); + let relative_path = path + .strip_prefix(root_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let document_id = parsed + .mnote_id + .as_deref() + .map(|id| format!("local-mdid:{id}")) + .unwrap_or_else(|| format!("local-md:{}", relative_path.replace('/', "~2F"))); + let metadata = fs::metadata(path).map_err(|error| { + WebError::bad_request_code( + "local_search_index_stat_failed", + format!( + "无法读取本地 Markdown 索引文件状态 {}: {error}", + path.display() + ), + ) + })?; + Ok(LocalSearchDocument { + document_id, + title: parsed.title, + path: relative_path, + raw_text: parsed.body.clone(), + tags: extract_tags(&markdown), + backlinks: extract_backlinks(&parsed.body), + resource_refs: extract_resource_refs(&parsed.body), + updated_at: metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_millis()) + .unwrap_or_default(), + }) +} + +fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> { + let index_dir = root_path.join(".mnote").join("index"); + fs::create_dir_all(&index_dir).map_err(|error| { + WebError::bad_request_code( + "local_search_index_create_failed", + format!("无法创建本地搜索索引目录 {}: {error}", index_dir.display()), + ) + })?; + let index_path = index_dir.join("search-index.json"); + let payload = serde_json::to_string_pretty(index) + .map_err(|error| WebError::internal(format!("本地搜索索引序列化失败: {error}")))?; + fs::write(&index_path, format!("{payload}\n")).map_err(|error| { + WebError::bad_request_code( + "local_search_index_write_failed", + format!("无法写入本地搜索索引 {}: {error}", index_path.display()), + ) + }) +} + +fn local_search_document_matches( + document: &LocalSearchDocument, + query: &str, + title_only: bool, + exact: bool, +) -> bool { + if query.is_empty() { + return false; + } + let haystack = if title_only { + normalize_search_text(&document.title) + } else { + normalize_search_text(&format!( + "{}\n{}\n{}\n{}", + document.title, + document.raw_text, + document.tags.join(" "), + document.resource_refs.join(" ") + )) + }; + if exact { + haystack == query + } else { + haystack.contains(query) + } +} + +fn local_search_document_projection( + document: &LocalSearchDocument, + root_uri: &str, + query: &str, +) -> Value { + json!({ + "id": document.document_id, + "documentId": document.document_id, + "title": document.title, + "path": document.path, + "resourceType": "markdown", + "sourceKind": "local_folder", + "rootUri": root_uri, + "snippet": search_snippet(document, query), + "tags": document.tags, + "backlinks": document.backlinks, + "resourceRefs": document.resource_refs, + "updatedAt": document.updated_at, + "publicPath": format!("/documents/{}?sourceKind=local_folder&rootUri={}", document.document_id, root_uri) + }) +} + +fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value { + let mut sorted = documents.iter().collect::>(); + sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + Value::Array( + sorted + .into_iter() + .take(20) + .map(|document| { + json!({ + "id": document.document_id, + "documentId": document.document_id, + "title": document.title, + "path": document.path, + "resourceType": "markdown", + "sourceKind": "local_folder", + "rootUri": root_uri, + "updatedAt": document.updated_at + }) + }) + .collect(), + ) +} + +fn normalized_document_targets( + document: &LocalSearchDocument, +) -> std::collections::BTreeSet { + let mut values = std::collections::BTreeSet::new(); + values.insert(normalize_search_text(&document.document_id)); + values.insert(normalize_search_text(&document.path)); + values.insert(normalize_search_text(&document.title)); + if let Some(encoded) = document.document_id.strip_prefix("local-md:") { + values.insert(normalize_search_text(&encoded.replace("~2F", "/"))); + } + values +} + +fn extract_tags(markdown: &str) -> Vec { + let Some(frontmatter) = split_frontmatter(markdown).0 else { + return Vec::new(); + }; + for line in frontmatter.lines() { + let trimmed = line.trim(); + let Some(value) = trimmed + .strip_prefix("tags:") + .or_else(|| trimmed.strip_prefix("tag:")) + else { + continue; + }; + return value + .trim() + .trim_matches(['[', ']']) + .split([',', ' ']) + .map(|item| item.trim().trim_matches(['"', '\''])) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect(); + } + Vec::new() +} + +fn extract_backlinks(markdown: &str) -> Vec { + let mut values = extract_markdown_links(markdown) + .into_iter() + .filter(|target| is_markdown_reference(target)) + .collect::>(); + let mut rest = markdown; + while let Some(start) = rest.find("[[") { + let after_start = &rest[start + 2..]; + let Some(end) = after_start.find("]]") else { + break; + }; + let link = after_start[..end].trim(); + if !link.is_empty() { + values.push(link.to_string()); + } + rest = &after_start[end + 2..]; + } + values.sort(); + values.dedup(); + values +} + +fn extract_resource_refs(markdown: &str) -> Vec { + let mut values = markdown + .lines() + .filter_map(|line| parse_markdown_attachment_link(line.trim())) + .map(|(_, target)| target) + .chain( + extract_markdown_links(markdown) + .into_iter() + .filter(|target| { + !is_markdown_reference(target) && is_local_resource_reference(target) + }), + ) + .collect::>(); + values.sort(); + values.dedup(); + values +} + +fn extract_markdown_links(markdown: &str) -> Vec { + let mut links = Vec::new(); + let mut rest = markdown; + while let Some(label_end) = rest.find("](") { + let after = &rest[label_end + 2..]; + let Some(target_end) = after.find(')') else { + break; + }; + let target = after[..target_end].trim(); + if !target.is_empty() { + links.push(target.to_string()); + } + rest = &after[target_end + 1..]; + } + links +} + +fn is_markdown_reference(target: &str) -> bool { + let lower = target.to_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") || lower.starts_with("local-md:") +} + +fn is_local_resource_reference(target: &str) -> bool { + let lower = target.to_lowercase(); + !lower.starts_with("http://") + && !lower.starts_with("https://") + && !lower.starts_with("mailto:") + && !lower.starts_with('#') + && Path::new(target).extension().is_some() +} + +fn search_snippet(document: &LocalSearchDocument, query: &str) -> String { + for line in document.raw_text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if normalize_search_text(trimmed).contains(query) { + return trimmed.chars().take(180).collect(); + } + } + document.raw_text.chars().take(180).collect() +} + +fn is_markdown_path(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown")) + .unwrap_or(false) +} + +fn normalize_search_text(value: &str) -> String { + value.trim().to_lowercase() +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn temp_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("docs")).expect("create root"); + root + } + + #[test] + fn local_search_index_extracts_backlinks_resource_refs_and_tags() { + let root = temp_root("mnote-local-search-index"); + fs::write( + root.join("README.md"), + "---\ntitle: Home\ntags: [alpha, beta]\n---\n# Home\nSee [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n[Map](maps/idea.mindmap.json)\n[Sheet](office/report.xlsx)\n![Diagram](assets/diagram.png)\n", + ) + .expect("write readme"); + fs::write( + root.join("docs").join("child.md"), + "---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n", + ) + .expect("write child"); + + let projection = query_local_search_index( + &root, + &format!("file://{}", root.display()), + "local-ws-test", + "alpha", + None, + 10, + false, + false, + ) + .expect("projection"); + let results = projection["results"].as_array().expect("results"); + let home = results + .iter() + .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) + .expect("home result"); + assert!(home["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag.as_str() == Some("alpha"))); + assert!(home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("Daily"))); + assert!(home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("docs/child.md"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("office/report.xlsx"))); + assert!(root + .join(".mnote") + .join("index") + .join("search-index.json") + .exists()); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 44b8e7a6..7e076e5c 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -13,6 +13,7 @@ mod kernel; mod local_folder_events; mod local_folder_source; mod local_markdown_parser; +mod local_search_index; mod media; mod mindmap_api; mod mindmap_shell; @@ -89,6 +90,18 @@ pub fn build_router(state: AppState) -> Router { get(web_shell::editor_image_placeholder_asset), ) .route("/api/search/documents", post(search::documents)) + .route( + "/api/search/local-index/refresh", + post(search::refresh_local_index), + ) + .route( + "/api/search/local-index/backlinks", + get(search::local_index_backlinks), + ) + .route( + "/api/search/local-index/tags", + get(search::local_index_tags), + ) .route("/api/gateway/health", get(gateway::gateway_health)) .route("/api/dev/hot-reload", get(dev_hot::hot_reload)) .route("/api/runtime/config", get(session::runtime_config)) diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index 2a2f240c..eb22da44 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -6,6 +6,7 @@ use crate::routes::query_support::{ resolve_effective_workspace_id, }; use crate::routes::web_shell::load_sidebar_tree_html; +use crate::routes::{local_folder_source, local_search_index}; use crate::ssr::pages::search::SearchPage; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; @@ -22,6 +23,8 @@ const HEADER_QUERY_NAME: &str = "x-query-name"; #[serde(rename_all = "camelCase")] pub struct SearchDocumentsRequest { pub workspace_id: Option, + pub source_kind: Option, + pub root_uri: Option, pub query: Option, pub document_id: Option, pub limit: Option, @@ -54,6 +57,21 @@ pub struct SearchShellQuery { pub q: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSearchIndexRefreshRequest { + pub workspace_id: Option, + pub root_uri: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSearchIndexQuery { + pub workspace_id: Option, + pub root_uri: String, + pub document_id: Option, +} + pub async fn shell( State(state): State, Extension(context): Extension, @@ -151,16 +169,40 @@ pub async fn documents( None }; - let result = load_search_results_with_filters( - state.config(), - &context, - &effective_workspace_id, - &normalized_query, - page_id, - body.limit.unwrap_or(30), - filters, - ) - .await?; + let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") { + let root_uri = body + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri") + .with_context(&context) + })?; + let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + local_search_index::query_local_search_index( + &root_path, + root_uri, + &effective_workspace_id, + &normalized_query, + page_id.as_deref(), + body.limit.unwrap_or(30), + filters.title_only.unwrap_or(false), + filters.exact.unwrap_or(false), + )? + } else { + load_search_results_with_filters( + state.config(), + &context, + &effective_workspace_id, + &normalized_query, + page_id, + body.limit.unwrap_or(30), + filters, + ) + .await? + }; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); @@ -170,7 +212,7 @@ pub async fn documents( Json(json!({ "results": result.get("results").cloned().unwrap_or(Value::Array(vec![])), "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), - "recent": [], + "recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])), "meta": { "owner": "mnote-web", "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), @@ -184,6 +226,138 @@ pub async fn documents( )) } +pub async fn refresh_local_index( + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let root_uri = body.root_uri.trim(); + if root_uri.is_empty() { + return Err(WebError::bad_request_code( + "local_search_root_required", + "本地索引刷新缺少 rootUri", + ) + .with_context(&context)); + } + let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let refreshed = local_search_index::refresh_local_search_index( + &root_path, + root_uri, + &effective_workspace_id, + )?; + let mut headers = HeaderMap::new(); + stamp_search_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "index": refreshed, + "meta": { + "owner": "mnote-web", + "projectionOwner": "rust-kernel", + "queryName": "search.local_index.refresh", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + } + })), + )) +} + +pub async fn local_index_backlinks( + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let root_uri = query.root_uri.trim(); + if root_uri.is_empty() { + return Err(WebError::bad_request_code( + "local_search_root_required", + "本地反链查询缺少 rootUri", + ) + .with_context(&context)); + } + let document_id = query + .document_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "local_search_document_required", + "本地反链查询缺少 documentId", + ) + .with_context(&context) + })?; + let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let backlinks = local_search_index::query_local_backlinks( + &root_path, + root_uri, + &effective_workspace_id, + document_id, + )?; + let mut headers = HeaderMap::new(); + stamp_search_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "result": backlinks, + "meta": { + "owner": "mnote-web", + "projectionOwner": "rust-kernel", + "queryName": "search.local_index.backlinks", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + } + })), + )) +} + +pub async fn local_index_tags( + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let root_uri = query.root_uri.trim(); + if root_uri.is_empty() { + return Err(WebError::bad_request_code( + "local_search_root_required", + "本地标签查询缺少 rootUri", + ) + .with_context(&context)); + } + let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?; + let mut headers = HeaderMap::new(); + stamp_search_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "result": tags, + "meta": { + "owner": "mnote-web", + "projectionOwner": "rust-kernel", + "queryName": "search.local_index.tags", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + } + })), + )) +} + async fn load_search_results( config: &crate::app::AppConfig, context: &RequestContext, @@ -365,6 +539,7 @@ mod tests { use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::{json, Value}; + use std::fs; use tower::util::ServiceExt; fn app() -> axum::Router { @@ -390,6 +565,18 @@ mod tests { })) } + fn query_escape(value: &str) -> String { + value + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect() + } + #[tokio::test] async fn search_shell_returns_server_first_island_contract() { let response = app() @@ -533,4 +720,227 @@ mod tests { assert_eq!(payload["meta"]["degraded"], true); assert!(!payload.to_string().contains("Hermes 技能知识图谱开发")); } + + #[tokio::test] + async fn search_documents_local_folder_uses_authorized_root_index() { + let root = + std::env::temp_dir().join(format!("mnote-local-search-route-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::create_dir_all(root.join("docs")).expect("docs"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-search","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("manifest"); + fs::write( + root.join("README.md"), + "---\ntitle: Search Home\ntags: [alpha]\n---\n# Search Home\nAlpha body links [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n", + ) + .expect("readme"); + fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/search/documents") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-search", + "sourceKind": "local_folder", + "rootUri": root_uri, + "query": "alpha", + "limit": 10 + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["meta"]["degraded"], false); + let results = payload["results"].as_array().expect("results"); + let home = results + .iter() + .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) + .expect("home result"); + assert_eq!(home["sourceKind"].as_str(), Some("local_folder")); + assert_eq!(home["resourceType"].as_str(), Some("markdown")); + assert!(home["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag.as_str() == Some("alpha"))); + assert!(home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("Daily"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); + assert!(root + .join(".mnote") + .join("index") + .join("search-index.json") + .exists()); + assert!(payload["recent"] + .as_array() + .unwrap() + .iter() + .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn search_local_index_refresh_rebuilds_authorized_root() { + let root = std::env::temp_dir().join(format!( + "mnote-local-search-refresh-route-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-refresh","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("manifest"); + fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/search/local-index/refresh") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-refresh", + "rootUri": root_uri + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["ok"], true); + assert_eq!(payload["index"]["documentCount"], 1); + assert_eq!( + payload["meta"]["queryName"].as_str(), + Some("search.local_index.refresh") + ); + let index = fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) + .expect("index"); + assert!(index.contains("refresh-token")); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn search_local_index_backlinks_and_tags_read_authorized_root() { + let root = std::env::temp_dir().join(format!( + "mnote-local-search-backlinks-route-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::create_dir_all(root.join("docs")).expect("docs"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-backlinks","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("manifest"); + fs::write( + root.join("README.md"), + "---\ntitle: Home\ntags: [alpha]\n---\n# Home\nSee [Child](docs/child.md).\n", + ) + .expect("home"); + fs::write( + root.join("docs").join("child.md"), + "---\ntitle: Child\ntags: [alpha, beta]\n---\n# Child\n", + ) + .expect("child"); + let root_uri = format!("file://{}", root.display()); + let encoded_root = query_escape(&root_uri); + + let backlinks_response = app() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/search/local-index/backlinks?workspaceId=local-ws-backlinks&rootUri={encoded_root}&documentId=local-md:docs~2Fchild.md" + )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("backlinks response"); + assert_eq!(backlinks_response.status(), StatusCode::OK); + let backlinks_body = to_bytes(backlinks_response.into_body(), usize::MAX) + .await + .expect("backlinks body"); + let backlinks_payload: Value = serde_json::from_slice(&backlinks_body).expect("json"); + assert_eq!( + backlinks_payload["meta"]["queryName"].as_str(), + Some("search.local_index.backlinks") + ); + assert!(backlinks_payload["result"]["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); + + let tags_response = app() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/search/local-index/tags?workspaceId=local-ws-backlinks&rootUri={encoded_root}" + )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("tags response"); + assert_eq!(tags_response.status(), StatusCode::OK); + let tags_body = to_bytes(tags_response.into_body(), usize::MAX) + .await + .expect("tags body"); + let tags_payload: Value = serde_json::from_slice(&tags_body).expect("json"); + assert!(tags_payload["result"]["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag["tag"].as_str() == Some("alpha") && tag["count"].as_u64() == Some(2))); + + let _ = fs::remove_dir_all(&root); + } } diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 85ed886a..2ea31345 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1571,6 +1571,27 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim(); }; + const conflictEnvelopeFromResponse = (payload) => ( + payload?.error?.details?.conflict + || payload?.details?.conflict + || null + ); + + const conflictSourceLabel = (session) => { + if (!session) return ''; + if (session.lastExternalWriteSource === 'mnote-hermes-tool') { + const runId = String(session.lastExternalWriteRunId || '').trim(); + return runId ? `agent run ${runId}` : 'agent run'; + } + return '本地文件变更'; + }; + + const conflictMessageFromEnvelope = (session, envelope) => { + const baseMessage = envelope?.message || externalConflictMessage; + const sourceLabel = conflictSourceLabel(session); + return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage; + }; + const clearSessionConflictSurface = (session) => { sessionViews(session).forEach((view) => { const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; @@ -1633,7 +1654,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { currentBox.append(currentTitle, current); const diskBox = document.createElement('section'); diskBox.append(diskTitle, disk); - diffPanel.append(currentBox, diskBox); + const mergeTitle = document.createElement('h3'); + mergeTitle.textContent = '合并结果'; + const mergeText = document.createElement('textarea'); + mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); + mergeText.value = sessionPlainText(session) || aggregatePlainText(latest) || ''; + const mergeActions = document.createElement('div'); + mergeActions.className = 'mnote-conflict-actions'; + const useCurrent = document.createElement('button'); + useCurrent.type = 'button'; + useCurrent.textContent = '使用当前版本'; + useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current'); + const useDisk = document.createElement('button'); + useDisk.type = 'button'; + useDisk.textContent = '使用磁盘版本'; + useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk'); + const saveMerge = document.createElement('button'); + saveMerge.type = 'button'; + saveMerge.textContent = '写回合并结果'; + saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save'); + mergeActions.append(useCurrent, useDisk, saveMerge); + const mergeBox = document.createElement('section'); + mergeBox.className = 'mnote-conflict-merge-box'; + mergeBox.append(mergeTitle, mergeText, mergeActions); + diffPanel.append(currentBox, diskBox, mergeBox); + useCurrent.addEventListener('click', () => { + mergeText.value = sessionPlainText(session) || ''; + }); + useDisk.addEventListener('click', () => { + mergeText.value = aggregatePlainText(latest) || ''; + }); + saveMerge.addEventListener('click', () => { + writeMergedConflictResult(session, panel, mergeText.value).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }); } catch (error) { loading.textContent = error instanceof Error ? error.message : String(error); diffPanel.replaceChildren(loading); @@ -1673,6 +1729,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { await persistSession(session); }; + const writeMergedConflictResult = async (session, panel, mergedText) => { + setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); + const latest = await fetchLatestSessionAggregate(session); + const nextKey = conflictDetectionKeyFromBody(latest.body || {}); + if (nextKey) { + session.conflictDetectionKey = nextKey; + session.lastExternalConflictDetectionKey = nextKey; + } + session.currentTiptapDocument = textToTiptapDocument(String(mergedText || '')); + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + session.hasExternalConflict = false; + session.externalChangePending = false; + session.saving = false; + session.dirty = true; + if (panel && typeof panel.remove === 'function') panel.remove(); + await persistSession(session); + }; + const renderSessionConflictSurface = (session, message) => { clearSessionConflictSurface(session); sessionViews(session).forEach((view) => { @@ -1690,7 +1764,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { text.textContent = message || externalConflictMessage; const meta = document.createElement('div'); meta.className = 'mnote-conflict-meta'; - meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`; + meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; const actions = document.createElement('div'); actions.className = 'mnote-conflict-actions'; const acceptDisk = document.createElement('button'); @@ -1744,8 +1818,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } - setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage); - renderSessionConflictSurface(session, message || externalConflictMessage); + const nextMessage = message || externalConflictMessage; + setSessionStatus(session, 'external-change-conflict', nextMessage); + renderSessionConflictSurface(session, nextMessage); }; const queueSessionSave = (session) => { @@ -1801,6 +1876,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const result = await response.json().catch(() => null); if (!response.ok || !result || result.ok !== true) { const message = result?.error?.message || result?.message || `save_failed_${response.status}`; + const conflictEnvelope = conflictEnvelopeFromResponse(result); + if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') { + session.saving = false; + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } throw new Error(message); } const saved = result.result || {}; @@ -1879,6 +1961,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return; } const payload = await response.json(); + const conflictEnvelope = conflictEnvelopeFromResponse(payload); + if (response.status === 409 && conflictEnvelope) { + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } const nextAggregate = payload?.result; const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; @@ -2137,8 +2225,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return; session.lastExternalChangeSignalAt = Date.now(); session.externalChangePending = true; + session.lastExternalWriteSource = source || session.lastExternalWriteSource || ''; + session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim(); + session.lastExternalWriteDocumentId = documentId; if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { - markSessionExternalConflict(session, treeExternalConflictMessage); + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage); return; } scheduled += 1; @@ -3676,6 +3767,9 @@ mod tests { assert!(html.contains("mnote-conflict-accept-disk")); assert!(html.contains("mnote-conflict-keep-current")); assert!(html.contains("mnote-conflict-open-diff")); + assert!(html.contains("mnote-conflict-merge-text")); + assert!(html.contains("mnote-conflict-merge-save")); + assert!(html.contains("agent run")); assert!(!html.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" )); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 540504a2..cdc426e9 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3806,6 +3806,8 @@ const SIDEBAR_TREE_JS: &str = r##" headers: { 'content-type': 'application/json' }, body: JSON.stringify({ workspaceId: resolveWorkspaceId(document.body), + sourceKind: currentSourceKind() || null, + rootUri: currentRootUri() || null, documentId: currentDocumentId() || null, query: query, limit: 30, @@ -3831,10 +3833,12 @@ const SIDEBAR_TREE_JS: &str = r##" var title = searchText(item.title || item.name || item.documentTitle || '无标题'); var snippet = searchText(item.snippet || (Array.isArray(item.evidence) && item.evidence[0] && item.evidence[0].snippet) || ''); var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName()); - return ''; }).join(''); } catch (error) { @@ -6407,6 +6411,27 @@ const SIDEBAR_TREE_JS: &str = r##" traceId: runTraceId, auditId: String(agentAudit.eventId || '') }); + try { + var currentId = currentDocumentId(); + var currentPath = String(currentId || '').replace(/^local-md:/, '').replace(/~2F/g, '/'); + var touchesCurrent = changedFiles.some(function(file) { + var path = String(file && (file.documentId || file.path || file.filePath || '') || ''); + return path === currentId || (currentPath && path.indexOf(currentPath) >= 0); + }); + if (touchesCurrent) { + window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { + detail: { + toolName: 'agent.changed_files', + normalizedToolName: 'agent.changed_files', + documentId: currentId, + workspaceId: resolveWorkspaceId(document.body), + runId: runId, + traceId: runTraceId, + toolCallId: runId + ':agent.changed_files' + } + })); + } + } catch (_) {} } } catch (_) {} pageAiSetRunStatus('completed', runId); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 1022aea2..7f242e21 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1190,6 +1190,9 @@ a:hover { .wolai-search-result-title{color:#2F2D29;font-size:14px;line-height:1.35;word-break:break-word} .wolai-search-result-title mark,.wolai-search-result-snippet mark{padding:0 1px;border-radius:2px;background:#FFE9E6;color:#D83A32} .wolai-search-result-snippet,.wolai-search-empty{color:#8B8780;font-size:12px;line-height:1.35} +.wolai-search-result-path{display:flex;align-items:center;gap:8px;color:#A09B93;font-size:11px;line-height:1.3;min-width:0} +.wolai-search-result-path span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.wolai-search-result-type{flex:0 0 auto;padding:1px 5px;border-radius:4px;background:rgba(55,53,47,.06);color:#6F6A63;text-transform:uppercase;font-size:10px;letter-spacing:0} .wolai-search-empty,.wolai-search-recent{padding:28px 12px 32px;text-align:center;color:#8B8780;font-size:12px;line-height:1.35} @media (max-width: 768px) { @@ -2544,6 +2547,7 @@ body { } .mnote-conflict-diff-panel pre, +.mnote-conflict-merge-box textarea, .mnote-conflict-diff-status { min-height: 96px; max-height: 260px; @@ -2558,6 +2562,16 @@ body { line-height: 18px; } +.mnote-conflict-merge-box { + grid-column: 1 / -1; +} + +.mnote-conflict-merge-box textarea { + width: 100%; + min-height: 132px; + resize: vertical; +} + @media (max-width: 768px) { .mnote-conflict-diff-panel { grid-template-columns: 1fr; diff --git a/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js b/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js index 55f7bb6c..d97348f8 100644 --- a/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js +++ b/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js @@ -113,9 +113,13 @@ async function run() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-")); const acceptFile = "accept-disk.md"; const keepFile = "keep-current.md"; + const mergeFile = "merge-result.md"; + const agentFile = "agent-conflict.md"; writeWorkspaceManifest(root, "user_real"); fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept"]), "utf8"); fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep"]), "utf8"); + fs.writeFileSync(path.join(root, mergeFile), markdown("Merge Result", ["initial merge"]), "utf8"); + fs.writeFileSync(path.join(root, agentFile), markdown("Agent Conflict", ["initial agent"]), "utf8"); const browser = await chromium.launch({ headless: true, @@ -189,6 +193,57 @@ async function run() { assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容"); steps.push({ label: "keep-current", ok: true }); + await openDocument(page, root, mergeFile); + await waitForEditorText(page, "initial merge"); + const localMergeToken = `local-merge-${Date.now()}`; + const diskMergeToken = `disk-merge-${Date.now()}`; + const mergedToken = `merged-merge-${Date.now()}`; + await typeDirtyText(page, ` ${localMergeToken}`); + fs.writeFileSync(path.join(root, mergeFile), markdown("Merge Result", ["initial merge", diskMergeToken]), "utf8"); + await waitForEditorStatus(page, "external-change-conflict"); + await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-merge-text"]').fill(`initial merge\n${localMergeToken}\n${diskMergeToken}\n${mergedToken}`); + await page.locator('[data-testid="mnote-conflict-merge-save"]').click({ timeout: UI_TIMEOUT_MS }); + await waitForFileText(path.join(root, mergeFile), mergedToken); + const mergeContent = fs.readFileSync(path.join(root, mergeFile), "utf8"); + assert(mergeContent.includes(localMergeToken), "合并结果应保留当前编辑器内容"); + assert(mergeContent.includes(diskMergeToken), "合并结果应保留磁盘内容"); + assert(mergeContent.includes(mergedToken), "合并结果应写入合并后的新内容"); + steps.push({ label: "merge-save", ok: true }); + + await openDocument(page, root, agentFile); + await waitForEditorText(page, "initial agent"); + const localAgentToken = `local-agent-${Date.now()}`; + const diskAgentToken = `disk-agent-${Date.now()}`; + const agentRunId = `run-agent-conflict-${Date.now()}`; + await typeDirtyText(page, ` ${localAgentToken}`); + fs.writeFileSync(path.join(root, agentFile), markdown("Agent Conflict", ["initial agent", diskAgentToken]), "utf8"); + await page.evaluate(({ documentId, runId }) => { + window.dispatchEvent(new CustomEvent("mnote:page-ai-tool-write-completed", { + detail: { + toolName: "agent.changed_files", + normalizedToolName: "agent.changed_files", + documentId, + runId, + traceId: `trace-${runId}`, + toolCallId: `${runId}:agent.changed_files`, + }, + })); + }, { documentId: localMdDocumentId(agentFile), runId: agentRunId }); + await waitForEditorStatus(page, "external-change-conflict"); + await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + (expected) => { + const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]'); + return (panel?.textContent || "").includes(expected); + }, + `agent run ${agentRunId}`, + { timeout: UI_TIMEOUT_MS }, + ); + steps.push({ label: "agent-conflict-source", ok: true }); + const result = { ok: true, baseUrl: BASE_URL, root, steps }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`); diff --git a/scripts/task452-local-search-index-browser-smoke.js b/scripts/task452-local-search-index-browser-smoke.js new file mode 100644 index 00000000..bd9a4f9b --- /dev/null +++ b/scripts/task452-local-search-index-browser-smoke.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task452-local-search-index-browser-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task452`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "search"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function browserSearch(page, root, query) { + return page.evaluate(async ({ rootUri, queryText }) => { + const response = await fetch("/api/search/documents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: "local-ws:user_real:task452", + sourceKind: "local_folder", + rootUri, + query: queryText, + limit: 10, + }), + }); + const payload = await response.json(); + return { + status: response.status, + payload, + }; + }, { rootUri: fileUrl(root), queryText: query }); +} + +async function run() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-search-smoke-")); + const debug = { root, baseUrl: BASE_URL }; + writeWorkspaceManifest(root, "user_real"); + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + fs.writeFileSync(path.join(root, "README.md"), "# Search Smoke\n打开搜索 smoke。\n", "utf8"); + + const browser = await chromium.launch({ + headless: true, + ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 860 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + }); + const page = await context.newPage(); + + try { + await page.goto(documentUrl(root, "README.md"), { + waitUntil: "domcontentloaded", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + const token = `LOCAL-SEARCH-${Date.now()}`; + const firstRelativePath = "docs/search-target.md"; + const renamedRelativePath = "docs/search-renamed.md"; + fs.writeFileSync( + path.join(root, firstRelativePath), + `---\ntitle: Search Target\n---\n# Search Target\n${token}\n`, + "utf8", + ); + + const first = await browserSearch(page, root, token); + assert.equal(first.status, 200, `初次搜索应成功: ${JSON.stringify(first)}`); + debug.first = first.payload; + const firstResults = Array.isArray(first.payload.results) ? first.payload.results : []; + assert( + firstResults.some((item) => item.path === firstRelativePath && item.documentId === localMdDocumentId(firstRelativePath)), + `新建页面应立即可搜索: ${JSON.stringify(firstResults)}`, + ); + + fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath)); + const second = await browserSearch(page, root, token); + assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`); + debug.second = second.payload; + const secondResults = Array.isArray(second.payload.results) ? second.payload.results : []; + assert( + secondResults.some((item) => item.path === renamedRelativePath && item.documentId === localMdDocumentId(renamedRelativePath)), + `重命名后搜索结果路径应更新: ${JSON.stringify(secondResults)}`, + ); + assert( + !secondResults.some((item) => item.path === firstRelativePath), + `重命名后搜索结果不应继续返回旧路径: ${JSON.stringify(secondResults)}`, + ); + + const result = { + ok: true, + root, + token, + firstPath: firstRelativePath, + renamedPath: renamedRelativePath, + indexExists: fs.existsSync(path.join(root, ".mnote", "index", "search-index.json")), + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`task452 local search index browser smoke passed: ${RESULT_PATH}`); + } catch (error) { + fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ + ok: false, + error: String(error && error.stack || error), + debug, + }, null, 2)}\n`, "utf8"); + throw error; + } finally { + await browser.close().catch(() => {}); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +});