feat: advance local-first conflict and search indexing
- 补齐本地 markdown 冲突处理与合并写回路径\n- 增加本地搜索索引路由、刷新与 browser smoke\n- 同步更新 current-priority checklist 的阶段进度
This commit is contained in:
+48
-19
@@ -88,7 +88,8 @@
|
|||||||
- 结论:本地 markdown aggregate 已暴露 `fileVersion` alias;`documents/save` compat 会把 `expectedFileVersion` / `conflictDetectionKey` 收敛到 `PageBodyWriteRequest.expected_file_version`;`/api/page-body/write` 只接受 `expectedFileVersion`,避免 alias 重复。
|
- 结论:本地 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。
|
- [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`。
|
- 实现: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。
|
- [x] 让冲突错误不丢失当前编辑器内容,前端可以继续保留未保存 buffer。
|
||||||
- 实现:冲突态优先从当前挂载的 ProseMirror DOM 捕获文本;“保留当前编辑器版本”会用最新 `fileVersion` 重新提交当前 buffer。
|
- 实现:冲突态优先从当前挂载的 ProseMirror DOM 捕获文本;“保留当前编辑器版本”会用最新 `fileVersion` 重新提交当前 buffer。
|
||||||
|
|
||||||
@@ -99,9 +100,12 @@
|
|||||||
- [x] 实现“接受磁盘版本”:重新读取文件,替换编辑器 buffer。
|
- [x] 实现“接受磁盘版本”:重新读取文件,替换编辑器 buffer。
|
||||||
- [x] 实现“保留编辑器版本”:用最新 fileVersion 重新提交当前编辑器内容。
|
- [x] 实现“保留编辑器版本”:用最新 fileVersion 重新提交当前编辑器内容。
|
||||||
- [x] 实现“打开 diff”:支持 markdown 文本并排查看;复杂块结构先降级为 markdown 文本。
|
- [x] 实现“打开 diff”:支持 markdown 文本并排查看;复杂块结构先降级为 markdown 文本。
|
||||||
- [ ] 实现“合并”:支持从 diff 面板选择合并结果并写回。
|
- [x] 实现“合并”:支持从 diff 面板选择合并结果并写回。
|
||||||
- [ ] 合并完成后写回本地 markdown,并刷新 page aggregate / file tree snapshot。
|
- 实现:冲突 diff 面板新增合并 textarea,可选择当前版本 / 磁盘版本作为合并基础,并写回合并结果。
|
||||||
- [ ] AI 写入导致冲突时,提示来源为 agent run,而不是普通外部修改。
|
- [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 <runId>`。
|
||||||
|
|
||||||
建议验证:
|
建议验证:
|
||||||
|
|
||||||
@@ -109,7 +113,8 @@
|
|||||||
- 验证:`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`
|
- 验证:`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`
|
||||||
- [x] 新增 browser smoke:浏览器打开页面后外部修改同一 `.md` 文件,保存时出现冲突 UI。
|
- [x] 新增 browser smoke:浏览器打开页面后外部修改同一 `.md` 文件,保存时出现冲突 UI。
|
||||||
- 验证:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
|
- 验证:`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 <runId>`。
|
||||||
|
|
||||||
补充验证:
|
补充验证:
|
||||||
|
|
||||||
@@ -173,31 +178,53 @@
|
|||||||
|
|
||||||
### 4.1 索引边界
|
### 4.1 索引边界
|
||||||
|
|
||||||
- [ ] 设计本地索引目录:建议放在 workspace `.mnote/index/` 或 `/mnt/Data1T/Mnote_data/control-plane/index/`,二者职责需明确。
|
- [x] 设计本地索引目录:建议放在 workspace `.mnote/index/` 或 `/mnt/Data1T/Mnote_data/control-plane/index/`,二者职责需明确。
|
||||||
- [ ] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。
|
- 实现:单 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。
|
- [ ] 定义索引输入: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 索引更新
|
### 4.2 索引更新
|
||||||
|
|
||||||
- [ ] 本地文件 watcher 事件触发增量索引。
|
- [ ] 本地文件 watcher 事件触发增量索引。
|
||||||
- [ ] 手动 refresh / resync 触发 root 全量索引。
|
- [x] 手动 refresh / resync 触发 root 全量索引。
|
||||||
- [ ] 文件移动、重命名、删除、恢复后同步更新索引。
|
- 实现:`POST /api/search/local-index/refresh` 会在授权 root 内全量重建 `.mnote/index/search-index.json`。
|
||||||
- [ ] AI 写入和 tiptap 保存后更新索引。
|
- 验证:`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 搜索体验
|
### 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。
|
- [x] 单测:markdown 链接和双链可生成 backlinks。
|
||||||
- [ ] 单测:附件 / mindmap / office 引用可生成 resource refs。
|
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
||||||
- [ ] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。
|
- [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/` 前必须满足:
|
本文件迁入 `done/` 前必须满足:
|
||||||
|
|
||||||
- [ ] P0 管理员目录授权 UI / API 完成并有 browser smoke。
|
- [x] P0 管理员目录授权 UI / API 完成并有 browser smoke。
|
||||||
- [ ] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。
|
- 证据:`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 查看。
|
- [ ] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。
|
||||||
- [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。
|
- [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。
|
||||||
- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。
|
- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use axum::http::{HeaderName, HeaderValue};
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -13,6 +14,8 @@ pub struct ErrorBody {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
pub request_id: Option<String>,
|
pub request_id: Option<String>,
|
||||||
pub trace_id: Option<String>,
|
pub trace_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -22,6 +25,7 @@ pub struct WebError {
|
|||||||
message: String,
|
message: String,
|
||||||
request_context: Option<RequestContext>,
|
request_context: Option<RequestContext>,
|
||||||
headers: Vec<(&'static str, String)>,
|
headers: Vec<(&'static str, String)>,
|
||||||
|
details: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebError {
|
impl WebError {
|
||||||
@@ -32,6 +36,7 @@ impl WebError {
|
|||||||
message: message.into(),
|
message: message.into(),
|
||||||
request_context: None,
|
request_context: None,
|
||||||
headers: Vec::new(),
|
headers: Vec::new(),
|
||||||
|
details: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +74,11 @@ impl WebError {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_details(mut self, details: Value) -> Self {
|
||||||
|
self.details = Some(details);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn message(&self) -> &str {
|
pub fn message(&self) -> &str {
|
||||||
&self.message
|
&self.message
|
||||||
}
|
}
|
||||||
@@ -96,6 +106,7 @@ impl IntoResponse for WebError {
|
|||||||
.request_context
|
.request_context
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|context| context.trace.trace_id.clone()),
|
.map(|context| context.trace.trace_id.clone()),
|
||||||
|
details: self.details,
|
||||||
};
|
};
|
||||||
let mut response = (self.status, Json(body)).into_response();
|
let mut response = (self.status, Json(body)).into_response();
|
||||||
if let Ok(name) = HeaderName::from_lowercase(b"x-error-code") {
|
if let Ok(name) = HeaderName::from_lowercase(b"x-error-code") {
|
||||||
|
|||||||
@@ -1558,6 +1558,32 @@ mod tests {
|
|||||||
.expect("save response");
|
.expect("save response");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
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");
|
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||||
assert!(markdown.contains("# External"));
|
assert!(markdown.contains("# External"));
|
||||||
assert!(!markdown.contains("# Editor"));
|
assert!(!markdown.contains("# Editor"));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::page_aggregate::{
|
|||||||
use crate::routes::local_markdown_parser::{
|
use crate::routes::local_markdown_parser::{
|
||||||
file_stem_title, parse_markdown_page, split_frontmatter,
|
file_stem_title, parse_markdown_page, split_frontmatter,
|
||||||
};
|
};
|
||||||
|
use crate::routes::local_search_index;
|
||||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query};
|
use axum::extract::{Extension, Multipart, Path as AxumPath, Query};
|
||||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
@@ -1380,10 +1381,11 @@ pub fn save_local_markdown_page(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
if expected_key != current_conflict_key {
|
if expected_key != current_conflict_key {
|
||||||
return Err(WebError::new(
|
return Err(local_markdown_conflict_error(
|
||||||
StatusCode::CONFLICT,
|
root_uri,
|
||||||
"local_markdown_external_change",
|
document_id,
|
||||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
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 =
|
let next_conflict_key =
|
||||||
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
|
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
|
||||||
Ok(json!({
|
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(
|
pub fn write_local_markdown_page_body(
|
||||||
request: &core_protocol::PageBodyWriteRequest,
|
request: &core_protocol::PageBodyWriteRequest,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
@@ -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(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"documentId": document_id,
|
"documentId": document_id,
|
||||||
@@ -1814,7 +1852,7 @@ pub fn execute_local_tree_command(
|
|||||||
format!("无法访问本地文件夹: {error}"),
|
format!("无法访问本地文件夹: {error}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
match action {
|
let result = match action {
|
||||||
"create" => {
|
"create" => {
|
||||||
create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面"))
|
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",
|
"local_tree_command_unsupported",
|
||||||
format!("local_folder 暂不支持 tree action: {other}"),
|
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(
|
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,
|
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
|
||||||
create_local_access_grant, ensure_local_path_read_access,
|
create_local_access_grant, ensure_local_path_read_access,
|
||||||
ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor,
|
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,
|
execute_local_tree_command, get_local_access_policy, initialize_local_page_id,
|
||||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision, open_local_file,
|
initialize_local_workspace_for_actor, load_local_folder_page_tree_snapshot,
|
||||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
local_folder_watch_revision, open_local_file, resolve_local_markdown_page_aggregate,
|
||||||
validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body,
|
save_local_markdown_page, validate_local_access_root, write_local_markdown_asset,
|
||||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
write_local_markdown_page_body, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||||
LocalUploadFile,
|
LocalFileOpenQuery, LocalUploadFile,
|
||||||
};
|
};
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::extract::{Extension, Path as AxumPath, Query};
|
use axum::extract::{Extension, Path as AxumPath, Query};
|
||||||
@@ -5353,6 +5394,88 @@ fn main() {}
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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]
|
#[test]
|
||||||
fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() {
|
fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() {
|
||||||
let root = temp_root("mnote-local-markdown-asset-upload");
|
let root = temp_root("mnote-local-markdown-asset-upload");
|
||||||
|
|||||||
@@ -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<LocalSearchDocument>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalSearchDocument {
|
||||||
|
document_id: String,
|
||||||
|
title: String,
|
||||||
|
path: String,
|
||||||
|
raw_text: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
backlinks: Vec<String>,
|
||||||
|
resource_refs: Vec<String>,
|
||||||
|
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<Value, WebError> {
|
||||||
|
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<Value, WebError> {
|
||||||
|
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<Value, WebError> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<Value, WebError> {
|
||||||
|
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||||
|
let mut tags = std::collections::BTreeMap::<String, Vec<&LocalSearchDocument>>::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::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_local_search_index(
|
||||||
|
root_path: &Path,
|
||||||
|
root_uri: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<LocalSearchIndex, WebError> {
|
||||||
|
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<LocalSearchDocument>,
|
||||||
|
) -> 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<LocalSearchDocument, WebError> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
let mut values = extract_markdown_links(markdown)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|target| is_markdown_reference(target))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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<String> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
values.sort();
|
||||||
|
values.dedup();
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_markdown_links(markdown: &str) -> Vec<String> {
|
||||||
|
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\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ mod kernel;
|
|||||||
mod local_folder_events;
|
mod local_folder_events;
|
||||||
mod local_folder_source;
|
mod local_folder_source;
|
||||||
mod local_markdown_parser;
|
mod local_markdown_parser;
|
||||||
|
mod local_search_index;
|
||||||
mod media;
|
mod media;
|
||||||
mod mindmap_api;
|
mod mindmap_api;
|
||||||
mod mindmap_shell;
|
mod mindmap_shell;
|
||||||
@@ -89,6 +90,18 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
get(web_shell::editor_image_placeholder_asset),
|
get(web_shell::editor_image_placeholder_asset),
|
||||||
)
|
)
|
||||||
.route("/api/search/documents", post(search::documents))
|
.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/gateway/health", get(gateway::gateway_health))
|
||||||
.route("/api/dev/hot-reload", get(dev_hot::hot_reload))
|
.route("/api/dev/hot-reload", get(dev_hot::hot_reload))
|
||||||
.route("/api/runtime/config", get(session::runtime_config))
|
.route("/api/runtime/config", get(session::runtime_config))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::routes::query_support::{
|
|||||||
resolve_effective_workspace_id,
|
resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
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 crate::ssr::pages::search::SearchPage;
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
@@ -22,6 +23,8 @@ const HEADER_QUERY_NAME: &str = "x-query-name";
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SearchDocumentsRequest {
|
pub struct SearchDocumentsRequest {
|
||||||
pub workspace_id: Option<String>,
|
pub workspace_id: Option<String>,
|
||||||
|
pub source_kind: Option<String>,
|
||||||
|
pub root_uri: Option<String>,
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub document_id: Option<String>,
|
pub document_id: Option<String>,
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
@@ -54,6 +57,21 @@ pub struct SearchShellQuery {
|
|||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalSearchIndexRefreshRequest {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub root_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalSearchIndexQuery {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub root_uri: String,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn shell(
|
pub async fn shell(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
@@ -151,16 +169,40 @@ pub async fn documents(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = load_search_results_with_filters(
|
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
||||||
state.config(),
|
let root_uri = body
|
||||||
&context,
|
.root_uri
|
||||||
&effective_workspace_id,
|
.as_deref()
|
||||||
&normalized_query,
|
.map(str::trim)
|
||||||
page_id,
|
.filter(|value| !value.is_empty())
|
||||||
body.limit.unwrap_or(30),
|
.ok_or_else(|| {
|
||||||
filters,
|
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
|
||||||
)
|
.with_context(&context)
|
||||||
.await?;
|
})?;
|
||||||
|
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();
|
let mut headers = HeaderMap::new();
|
||||||
stamp_search_headers(&mut headers);
|
stamp_search_headers(&mut headers);
|
||||||
@@ -170,7 +212,7 @@ pub async fn documents(
|
|||||||
Json(json!({
|
Json(json!({
|
||||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
||||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||||
"recent": [],
|
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
|
||||||
"meta": {
|
"meta": {
|
||||||
"owner": "mnote-web",
|
"owner": "mnote-web",
|
||||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
"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<RequestContext>,
|
||||||
|
Json(body): Json<LocalSearchIndexRefreshRequest>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
|
||||||
|
Query(query): Query<LocalSearchIndexQuery>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
|
||||||
|
Query(query): Query<LocalSearchIndexQuery>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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(
|
async fn load_search_results(
|
||||||
config: &crate::app::AppConfig,
|
config: &crate::app::AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -365,6 +539,7 @@ mod tests {
|
|||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::fs;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
fn app() -> axum::Router {
|
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]
|
#[tokio::test]
|
||||||
async fn search_shell_returns_server_first_island_contract() {
|
async fn search_shell_returns_server_first_island_contract() {
|
||||||
let response = app()
|
let response = app()
|
||||||
@@ -533,4 +720,227 @@ mod tests {
|
|||||||
assert_eq!(payload["meta"]["degraded"], true);
|
assert_eq!(payload["meta"]["degraded"], true);
|
||||||
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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) => {
|
const clearSessionConflictSurface = (session) => {
|
||||||
sessionViews(session).forEach((view) => {
|
sessionViews(session).forEach((view) => {
|
||||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
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);
|
currentBox.append(currentTitle, current);
|
||||||
const diskBox = document.createElement('section');
|
const diskBox = document.createElement('section');
|
||||||
diskBox.append(diskTitle, disk);
|
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) {
|
} catch (error) {
|
||||||
loading.textContent = error instanceof Error ? error.message : String(error);
|
loading.textContent = error instanceof Error ? error.message : String(error);
|
||||||
diffPanel.replaceChildren(loading);
|
diffPanel.replaceChildren(loading);
|
||||||
@@ -1673,6 +1729,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
await persistSession(session);
|
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) => {
|
const renderSessionConflictSurface = (session, message) => {
|
||||||
clearSessionConflictSurface(session);
|
clearSessionConflictSurface(session);
|
||||||
sessionViews(session).forEach((view) => {
|
sessionViews(session).forEach((view) => {
|
||||||
@@ -1690,7 +1764,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
text.textContent = message || externalConflictMessage;
|
text.textContent = message || externalConflictMessage;
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'mnote-conflict-meta';
|
meta.className = 'mnote-conflict-meta';
|
||||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`;
|
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'mnote-conflict-actions';
|
actions.className = 'mnote-conflict-actions';
|
||||||
const acceptDisk = document.createElement('button');
|
const acceptDisk = document.createElement('button');
|
||||||
@@ -1744,8 +1818,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
window.clearTimeout(session.saveTimer);
|
window.clearTimeout(session.saveTimer);
|
||||||
session.saveTimer = 0;
|
session.saveTimer = 0;
|
||||||
}
|
}
|
||||||
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
|
const nextMessage = message || externalConflictMessage;
|
||||||
renderSessionConflictSurface(session, message || externalConflictMessage);
|
setSessionStatus(session, 'external-change-conflict', nextMessage);
|
||||||
|
renderSessionConflictSurface(session, nextMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
const queueSessionSave = (session) => {
|
const queueSessionSave = (session) => {
|
||||||
@@ -1801,6 +1876,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
const result = await response.json().catch(() => null);
|
const result = await response.json().catch(() => null);
|
||||||
if (!response.ok || !result || result.ok !== true) {
|
if (!response.ok || !result || result.ok !== true) {
|
||||||
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
|
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);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
const saved = result.result || {};
|
const saved = result.result || {};
|
||||||
@@ -1879,6 +1961,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
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 nextAggregate = payload?.result;
|
||||||
const nextBody = nextAggregate?.body || {};
|
const nextBody = nextAggregate?.body || {};
|
||||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
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;
|
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
|
||||||
session.lastExternalChangeSignalAt = Date.now();
|
session.lastExternalChangeSignalAt = Date.now();
|
||||||
session.externalChangePending = true;
|
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) {
|
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||||
markSessionExternalConflict(session, treeExternalConflictMessage);
|
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduled += 1;
|
scheduled += 1;
|
||||||
@@ -3676,6 +3767,9 @@ mod tests {
|
|||||||
assert!(html.contains("mnote-conflict-accept-disk"));
|
assert!(html.contains("mnote-conflict-accept-disk"));
|
||||||
assert!(html.contains("mnote-conflict-keep-current"));
|
assert!(html.contains("mnote-conflict-keep-current"));
|
||||||
assert!(html.contains("mnote-conflict-open-diff"));
|
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(
|
assert!(!html.contains(
|
||||||
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -3806,6 +3806,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
workspaceId: resolveWorkspaceId(document.body),
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
|
sourceKind: currentSourceKind() || null,
|
||||||
|
rootUri: currentRootUri() || null,
|
||||||
documentId: currentDocumentId() || null,
|
documentId: currentDocumentId() || null,
|
||||||
query: query,
|
query: query,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
@@ -3831,10 +3833,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
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 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());
|
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '">' +
|
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||||
|
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '">' +
|
||||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||||
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(title, query) + '</span>' +
|
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(title, query) + '</span>' +
|
||||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span></span>' +
|
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span>' +
|
||||||
|
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
||||||
'</button>';
|
'</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -6407,6 +6411,27 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
traceId: runTraceId,
|
traceId: runTraceId,
|
||||||
auditId: String(agentAudit.eventId || '')
|
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 (_) {}
|
} catch (_) {}
|
||||||
pageAiSetRunStatus('completed', runId);
|
pageAiSetRunStatus('completed', runId);
|
||||||
|
|||||||
@@ -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{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-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-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}
|
.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) {
|
@media (max-width: 768px) {
|
||||||
@@ -2544,6 +2547,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mnote-conflict-diff-panel pre,
|
.mnote-conflict-diff-panel pre,
|
||||||
|
.mnote-conflict-merge-box textarea,
|
||||||
.mnote-conflict-diff-status {
|
.mnote-conflict-diff-status {
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
@@ -2558,6 +2562,16 @@ body {
|
|||||||
line-height: 18px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.mnote-conflict-diff-panel {
|
.mnote-conflict-diff-panel {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -113,9 +113,13 @@ async function run() {
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-"));
|
||||||
const acceptFile = "accept-disk.md";
|
const acceptFile = "accept-disk.md";
|
||||||
const keepFile = "keep-current.md";
|
const keepFile = "keep-current.md";
|
||||||
|
const mergeFile = "merge-result.md";
|
||||||
|
const agentFile = "agent-conflict.md";
|
||||||
writeWorkspaceManifest(root, "user_real");
|
writeWorkspaceManifest(root, "user_real");
|
||||||
fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept"]), "utf8");
|
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, 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({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -189,6 +193,57 @@ async function run() {
|
|||||||
assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容");
|
assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容");
|
||||||
steps.push({ label: "keep-current", ok: true });
|
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 };
|
const result = { ok: true, baseUrl: BASE_URL, root, steps };
|
||||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`);
|
console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user