feat: 收口本地文件夹入口并推进 page aggregate rust-first
- 为 Rust Web 主入口补齐本地文件夹/云空间切换、最近目录与路径回填体验\n- 对齐 local markdown media 与 inline marks 的 Rust shell / TipTap converter 语义\n- 让 documents/page 优先消费 Rust page aggregate snapshot,并保留 TS fallback\n- 补强 tree live、local markdown 与主入口 smoke,并同步设计稿状态
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# 3-5 [process] Rust Web 主 Web 执行面迁移实施计划 v1
|
||||
# 3-5 [done] Rust Web 主 Web 执行面迁移实施计划 v1
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 3-6 [process] Rust Web 主 Web 执行面迁移 checklist v1
|
||||
# 3-6 [done] Rust Web 主 Web 执行面迁移 checklist v1
|
||||
|
||||
> 更新时间:2026-04-28
|
||||
>
|
||||
|
||||
+3
-3
@@ -330,7 +330,7 @@
|
||||
|
||||
- [x] 保存回写时 task item 重新输出为标准 Markdown checkbox 语法。
|
||||
|
||||
- [ ] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。
|
||||
- [x] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。
|
||||
|
||||
### 11.6 Table 处理
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
|
||||
- [ ] 新增 web shell 单测,验证 legacy <-> Tiptap 转换不丢 marks。
|
||||
|
||||
- [ ] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。
|
||||
- [x] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。
|
||||
|
||||
- [x] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。
|
||||
|
||||
@@ -380,4 +380,4 @@
|
||||
|
||||
- [x] 保留回归测试和兼容层,不删除验证资产。
|
||||
|
||||
- [x] 迁移完成后把设计稿状态从 `process` 移到 `done`。
|
||||
- [ ] 迁移完成后把设计稿状态从 `process` 移到 `done`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 4-20 [process] VSCode Explorer 文件树对标设计 v1
|
||||
# 4-20 [done] VSCode Explorer 文件树对标设计 v1
|
||||
|
||||
> 更新时间:2026-05-06
|
||||
>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# 4-21 [process] Local Folder / Convex 统一树源架构 v1
|
||||
# 4-21 [done] Local Folder / Convex 统一树源架构 v1
|
||||
|
||||
> 更新时间:2026-05-06
|
||||
>
|
||||
> 关联:
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-20-vscode-explorer-file-tree-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
|
||||
>
|
||||
> 状态说明:当前 `3000` 主入口、本地文件夹打开、统一 `file_tree / page_tree / page_aggregate` 消费链已进入已完成基线;剩余收口继续由 `3-13`、`3-3` 等后续稿跟踪,本稿不再承载新的 process 项。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
|
||||
+5
-3
@@ -1,16 +1,18 @@
|
||||
# 4-22 [process] Local Folder / Convex 统一树源顺序执行清单 v1
|
||||
# 4-22 [done] Local Folder / Convex 统一树源顺序执行清单 v1
|
||||
|
||||
> 更新时间:2026-05-06
|
||||
>
|
||||
> 来源:
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-20-vscode-explorer-file-tree-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-21-local-folder-convex-unified-tree-source-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
|
||||
>
|
||||
> 状态说明:本清单对应的 Local Folder / Convex 统一树源主链已进入已完成基线;后续 Markdown parser、Page Aggregate Rust-first、tree live stream 与设计状态漂移由后续稿继续跟踪。
|
||||
|
||||
## 0. 执行原则
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
# 本地文件夹收尾与 Page Aggregate 主线回归实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 先把当前 `local_folder / local_markdown` 的未收口点收成稳定 checkpoint,再把文档页读取真相继续推进到 Rust 原生 `Page Aggregate`,最后补齐 `3000` 主界面的 tree live stream 浏览器级闭环。
|
||||
|
||||
**Architecture:** 保持 `WorkspaceSource -> Rust kernel / mnote-web -> projection / command -> UI` 的单一路径。第一段只处理当前未提交工作已经暴露出的本地/云空间切换、Markdown 附件与 marks 回归问题;第二段让 Next 侧 `/api/documents/page` 优先消费 Rust 已存在的 `mnote.page_aggregate.v1` 快照,而不是继续本地拼装 `meta + content`;第三段以 `snapshot + delta + resync` 的 Rust Web tree stream 作为 `3000` 当前 sidebar / filetree / page subtree 的正式实时主链。
|
||||
|
||||
**Tech Stack:** Rust (`mnote-web`, `core-protocol`), TypeScript/React (`wolai-frontend`), existing browser smoke scripts, Rust unit tests, Vitest, Playwright smoke.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 收口本地/云工作区切换入口与最近目录体验
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
|
||||
- Modify: `scripts/task164-desktop-hot-local-folder-main-entry-smoke.js`
|
||||
|
||||
- [x] **Step 1: 先把 smoke 断言补成失败用例**
|
||||
|
||||
```js
|
||||
const recentLocalRoots = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
|
||||
return JSON.parse(raw);
|
||||
});
|
||||
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
|
||||
|
||||
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-switch-cloud-workspace"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname === "/" &&
|
||||
url.searchParams.get("sourceKind") !== "local_folder" &&
|
||||
!url.searchParams.has("rootUri");
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行 smoke,确认当前基线失败或缺断言**
|
||||
|
||||
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js`
|
||||
|
||||
Expected: 失败,或日志中尚未覆盖“最近目录 / 切回云空间”链路。
|
||||
|
||||
- [x] **Step 3: 在 Rust SSR 壳里补最小实现**
|
||||
|
||||
```js
|
||||
function rememberCloudWorkspaceId(workspaceId) {
|
||||
var normalized = String(workspaceId || '').trim();
|
||||
if (!normalized || normalized === 'local-folder') return;
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY, normalized);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function switchToCloudWorkspace() {
|
||||
var targetUrl = new URL('/', window.location.origin);
|
||||
var workspaceId = readLastCloudWorkspaceId();
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
targetUrl.searchParams.set('sourceKind', 'convex_workspace');
|
||||
window.location.href = targetUrl.toString();
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
.mnote-workspace-source-menu {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: calc(100% + 6px);
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: 重新运行 smoke,确认体验闭环**
|
||||
|
||||
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js`
|
||||
|
||||
Expected: PASS,并打印 `task164 desktop hot local folder main entry smoke passed`。
|
||||
|
||||
### Task 2: 收口 local markdown 附件 media 与 marks 可见化漂移
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
- Modify: `wolai-frontend/src/lib/documents/tiptap-content-converter.ts`
|
||||
- Modify: `wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts`
|
||||
|
||||
- [x] **Step 1: 先写失败测试,钉死 Rust/前端两边都要可见且保留链接**
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
|
||||
assert!(html.contains("Spec"));
|
||||
assert!(html.contains("assets/spec.pdf"));
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m1",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
marks: [{ type: "link", attrs: { href: "assets/spec.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行最小测试,确认当前基线暴露漂移**
|
||||
|
||||
Run: `cd rust && cargo test -p mnote-web document_shell_renders_local_markdown_attachment_name_in_html -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/tiptap-content-converter.test.ts`
|
||||
|
||||
Expected: 前端测试失败,原因是 `media` 当前仍会丢掉 link mark;Rust 测试要么已过,要么暴露 HTML 可见化问题。
|
||||
|
||||
- [x] **Step 3: 做最小实现,统一 Rust adapter 与前端 converter 的 media 语义**
|
||||
|
||||
```ts
|
||||
case "media": {
|
||||
const sourcePath = toText(props.sourcePath ?? props.url ?? props.src).trim();
|
||||
const name = toText(props.name ?? props.fileName ?? props.title).trim() || sourcePath;
|
||||
return {
|
||||
blockId,
|
||||
blockType: "paragraph",
|
||||
props: {},
|
||||
contentNodes: name
|
||||
? [{
|
||||
type: "text",
|
||||
text: name,
|
||||
marks: sourcePath ? [{ type: "link", attrs: { href: sourcePath } }] : [],
|
||||
}]
|
||||
: contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: 跑回归,确认 local markdown 不再出现 Rust/前端双轨**
|
||||
|
||||
Run: `cd rust && cargo test -p mnote-web local_markdown -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/tiptap-content-converter.test.ts`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 3: 补齐 3-13 的浏览器回归矩阵,再同步设计稿状态
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/task163-local-folder-unified-tree-browser-smoke.js`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
- Modify: `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
|
||||
|
||||
- [x] **Step 1: 给本地 Markdown smoke 补齐缺的可见语义断言**
|
||||
|
||||
```js
|
||||
await expect(page.locator(".ProseMirror input[type='checkbox']")).toHaveCount(2);
|
||||
await expect(page.locator(".ProseMirror code")).toContainText(["inline code"]);
|
||||
await expect(page.locator(".ProseMirror strong")).toContainText(["bold"]);
|
||||
await expect(page.locator(".ProseMirror em")).toContainText(["italic"]);
|
||||
await expect(page.locator(".ProseMirror s")).toContainText(["strike"]);
|
||||
await expect(page.locator(".ProseMirror a")).toContainText(["Link", "Spec"]);
|
||||
await expect(page.locator(".ProseMirror table td code")).toContainText(["cell"]);
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行浏览器 smoke,确认 3-13 里剩余未勾项真实失败点**
|
||||
|
||||
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task163-local-folder-unified-tree-browser-smoke.js`
|
||||
|
||||
Expected: 至少暴露以下之一:checked/unchecked 断言不稳定、table cell marks 丢失、link/strike 渲染不完整。
|
||||
|
||||
- [x] **Step 3: 最小修复 parser / shell 映射**
|
||||
|
||||
```rust
|
||||
// 在 local_markdown_parser.rs 里固定空段落 / 空单元格 / 空引用块的降级节点;
|
||||
// 在 web_shell.rs 里保证 legacy <-> Tiptap 映射不把 inline marks 吞成纯文本。
|
||||
```
|
||||
|
||||
- [x] **Step 4: 复跑 smoke,并回写设计稿勾选状态**
|
||||
|
||||
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task163-local-folder-unified-tree-browser-smoke.js && rg -n "\\- \\[ \\]" design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
|
||||
|
||||
Expected: smoke PASS;`3-13` 只保留真正还没完成的收尾项,不再误把已验证项留成未完成。
|
||||
|
||||
### Task 4: 让 Next `/api/documents/page` 优先消费 Rust `Page Aggregate` 快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
|
||||
- Modify: `wolai-frontend/src/app/api/documents/page/route.ts`
|
||||
- Modify: `wolai-frontend/src/app/api/documents/page/route.test.ts`
|
||||
- Modify: `wolai-frontend/src/lib/documents/page-aggregate-builder.ts`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` (only if Rust snapshot contract 缺字段)
|
||||
|
||||
- [x] **Step 1: 先写失败测试,要求 happy path 直接吃 Rust 的 `mnote.page_aggregate.v1`**
|
||||
|
||||
```ts
|
||||
it("documents/page 路由优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
|
||||
);
|
||||
const payload = await response.json();
|
||||
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
|
||||
expect(payload.meta.queryName).toBe("documents.page.get");
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行路由测试,确认当前实现仍停留在 Next 本地组装**
|
||||
|
||||
Run: `cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/documents/page/route.test.ts`
|
||||
|
||||
Expected: 失败,原因是 loader 仍依赖 `api.documents.getMeta + documents.content.get + buildPageAggregateFromDocumentPayloads()`。
|
||||
|
||||
- [x] **Step 3: 做最小 cutover,实现“Rust first,TS builder fallback only”**
|
||||
|
||||
```ts
|
||||
const rustPage = await loadPageAggregateFromRustSnapshot({
|
||||
request: input.request,
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (rustPage) {
|
||||
return {
|
||||
page: rustPage,
|
||||
bridge: { requestId, traceId, queryName: "documents.page.get" },
|
||||
};
|
||||
}
|
||||
return buildPageAggregateFromDocumentPayloads({ meta, contentPayload });
|
||||
```
|
||||
|
||||
- [x] **Step 4: 跑 route + builder 回归,确认 `Page Aggregate` 读取真相继续向 Rust 收口**
|
||||
|
||||
Run: `cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/documents/page/route.test.ts src/lib/documents/page-aggregate-builder.test.ts`
|
||||
|
||||
Expected: PASS,并且 `page-aggregate-builder.ts` 退为 fallback / adapter 角色,而不是主 happy path。
|
||||
|
||||
### Task 5: 补齐 `3000` 当前主界面的 tree live stream 浏览器级闭环
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/stream_support.rs`
|
||||
- Modify: `scripts/task120-rust-web-tree-integration-smoke.js`
|
||||
- Modify: `scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`
|
||||
- Modify: `scripts/task165-rust-web-dual-pane-smoke.js`
|
||||
|
||||
- [x] **Step 1: 先把浏览器 smoke 补成“当前壳必须真的应用 delta / resync”**
|
||||
|
||||
```js
|
||||
const applied = await page.locator("html").getAttribute("data-mnote-tree-live-applied");
|
||||
assert(["delta", "resync"].includes(applied || ""), `tree live 应应用 delta/resync,实际: ${applied}`);
|
||||
|
||||
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行 tree live smoke,确认当前 3000 主壳的缺口**
|
||||
|
||||
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integration-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task165-rust-web-dual-pane-smoke.js`
|
||||
|
||||
Expected: 至少暴露以下之一:delta 只存在接口层、主壳未真正打到 DOM;resync fallback 未反映到属性或树快照;双 pane 重复建流。
|
||||
|
||||
- [x] **Step 3: 确认现有 `layout.rs` controller 与 delta/resync 选择逻辑已满足,本轮只补 smoke 断言**
|
||||
|
||||
```js
|
||||
source.addEventListener('delta', function(event) {
|
||||
var payload = parseTreeEventPayload(event);
|
||||
dispatchTreeEvent('tree:delta', { payload: payload, revision: readPayloadRevision(payload), bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('resync', function(event) {
|
||||
var payload = parseTreeEventPayload(event);
|
||||
dispatchTreeEvent('tree:resync', { payload: payload, revision: readPayloadRevision(payload), bootstrap: bootstrap });
|
||||
});
|
||||
```
|
||||
|
||||
```rust
|
||||
if let Some(delta) = change.delta {
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
return Some((Ok(stream_event("resync", &with_stream_kind(&snapshot_payload, "resync"))), Some(state)));
|
||||
```
|
||||
|
||||
- [x] **Step 4: 复跑 smoke,确认 `3-3` 可以继续向 done 靠拢**
|
||||
|
||||
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integration-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task165-rust-web-dual-pane-smoke.js`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 6: 做设计状态复核,清掉 `done/` 与 `[process]` 漂移
|
||||
|
||||
**Files:**
|
||||
- Modify: `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
- Modify: `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
|
||||
- Modify: `design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md`
|
||||
- Modify: `design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
|
||||
|
||||
- [x] **Step 1: 先跑状态检查,找出 `done/` 下仍写 `[process]` 的文档**
|
||||
|
||||
Run: `rg -n "^# .*\\[process\\]" design/03-rust-web/done design/04-tree-domain/done design/05-editor-mainline/done`
|
||||
|
||||
Expected: 能直接列出当前状态漂移文件。
|
||||
|
||||
- [x] **Step 2: 按真实验证结果更新头部与完成描述**
|
||||
|
||||
```md
|
||||
# 4-21 [done] Local Folder / Convex 统一树源架构 v1
|
||||
|
||||
补充:当前 `3000` 主入口、本地文件夹打开、统一 `file_tree / page_tree / page_aggregate` 消费链已完成;
|
||||
仍在推进的工作移交给 `3-13`、`3-14`、`3-3`,不再继续混写在本稿里。
|
||||
```
|
||||
|
||||
- [x] **Step 3: 复跑一致性检查**
|
||||
|
||||
Run: `rg -n "^# .*\\[(process|done)\\]" design/03-rust-web/done design/04-tree-domain/done design/05-editor-mainline/done`
|
||||
|
||||
Expected: `done/` 目录下不再残留明显错误的 `[process]` 头部。
|
||||
@@ -695,6 +695,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return '';
|
||||
};
|
||||
|
||||
const firstNonEmptyText = (...values) => {
|
||||
for (const value of values) {
|
||||
const text = flattenText(value).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const legacyStylesToTiptapMarks = (styles) => {
|
||||
if (!styles || typeof styles !== 'object') return [];
|
||||
const marks = [];
|
||||
@@ -712,13 +720,41 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return marks;
|
||||
};
|
||||
|
||||
const legacyMarkArrayToTiptapMarks = (inlineMarks) => {
|
||||
if (!Array.isArray(inlineMarks)) return [];
|
||||
return inlineMarks.flatMap((mark) => {
|
||||
if (!mark || typeof mark !== 'object') return [];
|
||||
if (mark.type === 'bold' || mark.type === 'italic' || mark.type === 'underline' || mark.type === 'strike' || mark.type === 'code') {
|
||||
return [{ type: mark.type }];
|
||||
}
|
||||
if (mark.type === 'link') {
|
||||
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
|
||||
return href ? [{ type: 'link', attrs: { href } }] : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const mergeTiptapMarks = (...groups) => {
|
||||
const seen = new Set();
|
||||
return groups.flat().filter((mark) => {
|
||||
const key = `${mark.type}:${JSON.stringify(mark.attrs || {})}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const legacyInlineContentToTiptap = (value) => {
|
||||
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
|
||||
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
|
||||
if (value && typeof value === 'object') {
|
||||
const text = typeof value.text === 'string' ? value.text : '';
|
||||
if (text) {
|
||||
const marks = legacyStylesToTiptapMarks(value.styles);
|
||||
const marks = mergeTiptapMarks(
|
||||
legacyStylesToTiptapMarks(value.styles),
|
||||
legacyMarkArrayToTiptapMarks(value.marks)
|
||||
);
|
||||
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
|
||||
}
|
||||
return legacyInlineContentToTiptap(value.content || value.contentNodes);
|
||||
@@ -772,6 +808,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
|
||||
}
|
||||
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
||||
if (type === 'media') {
|
||||
const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src);
|
||||
const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath);
|
||||
const mediaContent = name
|
||||
? [{
|
||||
type: 'text',
|
||||
text: name,
|
||||
...(sourcePath ? { marks: [{ type: 'link', attrs: { href: sourcePath } }] } : {}),
|
||||
}]
|
||||
: content;
|
||||
return { type: 'paragraph', attrs: withTextAlign({ blockId }), content: mediaContent };
|
||||
}
|
||||
if (type === 'table') {
|
||||
const tableSnapshot = block?.props?.tiptapTable;
|
||||
if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot;
|
||||
@@ -2606,6 +2654,44 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-document-shell-media-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(
|
||||
root.join("docs").join("blocks.md"),
|
||||
"---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Spec"));
|
||||
assert!(html.contains("assets/spec.pdf"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_secondary_pane_contract_when_query_present() {
|
||||
let response = app()
|
||||
@@ -2652,6 +2738,8 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("legacyInlineContentToTiptap"));
|
||||
assert!(html.contains("legacyStylesToTiptapMarks"));
|
||||
assert!(html.contains("legacyMarkArrayToTiptapMarks"));
|
||||
assert!(html.contains("firstNonEmptyText(block?.props?.sourcePath"));
|
||||
assert!(html.contains("marks.push({ type: 'code' })"));
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
|
||||
@@ -11,6 +11,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
|
||||
var MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode';
|
||||
var MNOTE_RECENT_LOCAL_ROOTS_KEY = 'mnote.localFolder.recentRoots';
|
||||
var MNOTE_LAST_CLOUD_WORKSPACE_KEY = 'mnote.workspace.lastCloudWorkspaceId';
|
||||
var MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = 'mnote.global.showHeadingNumbers';
|
||||
var mnoteNavigationInFlight = '';
|
||||
var draggingPageNodeId = '';
|
||||
@@ -298,6 +299,34 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function currentSourceKind() {
|
||||
return (new URLSearchParams(window.location.search).get('sourceKind') || 'convex_workspace').trim() || 'convex_workspace';
|
||||
}
|
||||
|
||||
function rememberCloudWorkspaceId(workspaceId) {
|
||||
var normalized = String(workspaceId || '').trim();
|
||||
if (!normalized || normalized === 'local-folder') return;
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY, normalized);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function readLastCloudWorkspaceId() {
|
||||
try {
|
||||
var stored = window.localStorage ? window.localStorage.getItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY) : '';
|
||||
if (stored && stored.trim()) return stored.trim();
|
||||
} catch (_) {}
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = (params.get('workspaceId') || '').trim();
|
||||
if (fromUrl && currentSourceKind() !== 'local_folder') return fromUrl;
|
||||
var fromDom = document.querySelector('[data-workspace-id]');
|
||||
if (fromDom instanceof HTMLElement) {
|
||||
var value = (fromDom.getAttribute('data-workspace-id') || '').trim();
|
||||
if (value && value !== 'local-folder') return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function copyWorkspaceSourceParams(targetUrl) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
|
||||
@@ -347,7 +376,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function fileRootUriToPathInput(rootUri) {
|
||||
var value = String(rootUri || '').replace(/^file:\/\//, '');
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function openLocalFolderRoot(rootUri) {
|
||||
if (currentSourceKind() !== 'local_folder') {
|
||||
rememberCloudWorkspaceId(resolveWorkspaceId(document.body));
|
||||
}
|
||||
rememberLocalRoot(rootUri);
|
||||
var targetUrl = new URL('/', window.location.origin);
|
||||
targetUrl.searchParams.set('treeView', 'filetree');
|
||||
@@ -356,6 +397,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.location.href = targetUrl.toString();
|
||||
}
|
||||
|
||||
function switchToCloudWorkspace() {
|
||||
var targetUrl = new URL('/', window.location.origin);
|
||||
var workspaceId = readLastCloudWorkspaceId();
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
targetUrl.searchParams.set('sourceKind', 'convex_workspace');
|
||||
window.location.href = targetUrl.toString();
|
||||
}
|
||||
|
||||
function closeLocalFolderDialog() {
|
||||
var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
@@ -461,7 +510,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
input.spellcheck = false;
|
||||
input.placeholder = '/mnt/Data1T/mnote/design/04-tree-domain/done';
|
||||
input.setAttribute('data-testid', 'mnote-local-folder-path-input');
|
||||
input.value = recent.length ? recent[0].replace(/^file:\/\//, '') : '';
|
||||
input.value = recent.length ? fileRootUriToPathInput(recent[0]) : '';
|
||||
input.style.width = '100%';
|
||||
input.style.boxSizing = 'border-box';
|
||||
input.style.border = '1px solid rgba(27, 28, 28, 0.18)';
|
||||
@@ -536,6 +585,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
card.appendChild(status);
|
||||
card.appendChild(input);
|
||||
if (recent.length) {
|
||||
var recentTitle = document.createElement('div');
|
||||
recentTitle.style.fontSize = '12px';
|
||||
recentTitle.style.color = '#6b7280';
|
||||
recentTitle.textContent = '最近使用';
|
||||
card.appendChild(recentTitle);
|
||||
var recentList = document.createElement('div');
|
||||
recentList.className = 'mnote-local-folder-dialog__recent';
|
||||
recent.slice(0, 5).forEach(function(rootUri) {
|
||||
@@ -551,13 +605,6 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
card.appendChild(recentList);
|
||||
}
|
||||
if (recent.length) {
|
||||
var recentTitle = document.createElement('div');
|
||||
recentTitle.style.fontSize = '12px';
|
||||
recentTitle.style.color = '#6b7280';
|
||||
recentTitle.textContent = '最近使用';
|
||||
card.insertBefore(recentTitle, actions);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
dialog.appendChild(card);
|
||||
dialog.addEventListener('click', function(event) {
|
||||
@@ -572,6 +619,79 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
openLocalFolderDialog('');
|
||||
}
|
||||
|
||||
function closeWorkspaceSourceMenu() {
|
||||
var existing = document.querySelector('[data-testid="mnote-workspace-source-menu"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
document.querySelectorAll('[data-testid="mnote-workspace-source-trigger"]').forEach(function(trigger) {
|
||||
if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceSourceLabel(rootUri) {
|
||||
var label = String(rootUri || '').replace(/^file:\/\//, '');
|
||||
try {
|
||||
label = decodeURIComponent(label);
|
||||
} catch (_) {}
|
||||
return label || '本地文件夹';
|
||||
}
|
||||
|
||||
function openWorkspaceSourceMenu(trigger) {
|
||||
closeWorkspaceSourceMenu();
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'mnote-workspace-source-menu';
|
||||
menu.setAttribute('data-testid', 'mnote-workspace-source-menu');
|
||||
menu.setAttribute('role', 'menu');
|
||||
var cloudButton = document.createElement('button');
|
||||
cloudButton.type = 'button';
|
||||
cloudButton.className = 'mnote-workspace-source-menu__item';
|
||||
cloudButton.setAttribute('data-testid', 'mnote-switch-cloud-workspace');
|
||||
cloudButton.setAttribute('role', 'menuitem');
|
||||
cloudButton.textContent = '云空间';
|
||||
cloudButton.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeWorkspaceSourceMenu();
|
||||
switchToCloudWorkspace();
|
||||
});
|
||||
menu.appendChild(cloudButton);
|
||||
var recent = readRecentLocalRoots();
|
||||
if (recent.length) {
|
||||
var label = document.createElement('div');
|
||||
label.className = 'mnote-workspace-source-menu__label';
|
||||
label.textContent = '最近本地文件夹';
|
||||
menu.appendChild(label);
|
||||
recent.slice(0, 8).forEach(function(rootUri) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'mnote-workspace-source-menu__item';
|
||||
button.setAttribute('data-testid', 'mnote-recent-local-root');
|
||||
button.setAttribute('data-root-uri', rootUri);
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.textContent = workspaceSourceLabel(rootUri);
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeWorkspaceSourceMenu();
|
||||
openLocalFolderRoot(rootUri);
|
||||
});
|
||||
menu.appendChild(button);
|
||||
});
|
||||
}
|
||||
var openOther = document.createElement('button');
|
||||
openOther.type = 'button';
|
||||
openOther.className = 'mnote-workspace-source-menu__item mnote-workspace-source-menu__item--primary';
|
||||
openOther.setAttribute('data-testid', 'mnote-open-other-local-folder');
|
||||
openOther.setAttribute('role', 'menuitem');
|
||||
openOther.textContent = '打开其他本地文件夹';
|
||||
openOther.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeWorkspaceSourceMenu();
|
||||
requestOpenLocalFolder();
|
||||
});
|
||||
menu.appendChild(openOther);
|
||||
trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu);
|
||||
}
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
|
||||
@@ -2280,6 +2400,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceMenuTrigger = closestAction(e.target, '[data-mnote-action="open-workspace-source-menu"]');
|
||||
if (sourceMenuTrigger) {
|
||||
e.preventDefault();
|
||||
var existingSourceMenu = document.querySelector('[data-testid="mnote-workspace-source-menu"]');
|
||||
if (existingSourceMenu) closeWorkspaceSourceMenu();
|
||||
else openWorkspaceSourceMenu(sourceMenuTrigger);
|
||||
return;
|
||||
}
|
||||
var sourceMenu = closestAction(e.target, '[data-testid="mnote-workspace-source-menu"]');
|
||||
if (!sourceMenu) closeWorkspaceSourceMenu();
|
||||
|
||||
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
|
||||
if (tabTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -2871,8 +3002,18 @@ pub fn PageLayout(
|
||||
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
|
||||
<div class="mnote-sidebar-header wolai-sidebar-header" data-testid="wolai-workspace-identity">
|
||||
<a href="/" class="mnote-sidebar-brand wolai-avatar" aria-label="工作区首页">"L"</a>
|
||||
<span class="sidebar-workspace-name">{ws_name.clone()}</span>
|
||||
<span class="wolai-sidebar-chevron" aria-hidden="true">"⌄"</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-workspace-source-trigger"
|
||||
data-testid="mnote-workspace-source-trigger"
|
||||
data-mnote-action="open-workspace-source-menu"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
title="切换云空间或本地文件夹"
|
||||
>
|
||||
<span class="sidebar-workspace-name">{ws_name.clone()}</span>
|
||||
<span class="wolai-sidebar-chevron" aria-hidden="true">"⌄"</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
||||
@@ -2931,6 +3072,7 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_SIDEBAR_TREE_MODE_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("restoreSidebarTreeTab"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("treeView"));
|
||||
@@ -2947,6 +3089,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -233,6 +233,7 @@ a:hover {
|
||||
gap: 10px;
|
||||
padding: 12px 16px 8px;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wolai-avatar {
|
||||
@@ -256,12 +257,75 @@ a:hover {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.mnote-workspace-source-trigger {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-workspace-source-trigger:hover,
|
||||
.mnote-workspace-source-trigger[aria-expanded="true"] {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.wolai-sidebar-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mnote-workspace-source-menu {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 120;
|
||||
width: 250px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.mnote-workspace-source-menu__label {
|
||||
padding: 8px 8px 4px;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-workspace-source-menu__item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
padding: 8px;
|
||||
color: var(--wolai-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-workspace-source-menu__item:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-workspace-source-menu__item--primary {
|
||||
color: #1D4ED8;
|
||||
}
|
||||
|
||||
.wolai-quick-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
|
||||
@@ -5,7 +5,7 @@ const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
@@ -69,6 +69,65 @@ async function waitForSidebarRow(page, documentId) {
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
async function armTreeLiveProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
const key = "__task120TreeLiveEvents";
|
||||
window[key] = [];
|
||||
if (window.__task120TreeLiveProbeArmed) return;
|
||||
const push = (kind, detail) => {
|
||||
const payload = detail && typeof detail === "object" && "payload" in detail ? detail.payload : detail;
|
||||
window[key].push({
|
||||
kind,
|
||||
applied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
payload,
|
||||
});
|
||||
if (window[key].length > 20) window[key].shift();
|
||||
};
|
||||
window.addEventListener("tree:delta", (event) => push("delta", event.detail));
|
||||
window.addEventListener("tree:resync", (event) => push("resync", event.detail));
|
||||
window.__task120TreeLiveProbeArmed = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTreeLiveApplied(page, label, expectedTitle) {
|
||||
await page.waitForFunction(
|
||||
({ title }) => {
|
||||
const events = Array.isArray(window.__task120TreeLiveEvents) ? window.__task120TreeLiveEvents : [];
|
||||
return events.some((event) => {
|
||||
if (!event || (event.kind !== "delta" && event.kind !== "resync")) return false;
|
||||
const applied = event.applied || document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
|
||||
if (applied !== "delta" && applied !== "resync") return false;
|
||||
try {
|
||||
return JSON.stringify(event.payload || {}).includes(title);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ title: expectedTitle },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const probe = await page.evaluate(({ title }) => {
|
||||
const events = Array.isArray(window.__task120TreeLiveEvents) ? window.__task120TreeLiveEvents : [];
|
||||
const match = events.find((event) => {
|
||||
if (!event || (event.kind !== "delta" && event.kind !== "resync")) return false;
|
||||
try {
|
||||
return JSON.stringify(event.payload || {}).includes(title);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
applied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
kind: match?.kind || "",
|
||||
};
|
||||
}, { title: expectedTitle });
|
||||
assert(["delta", "resync"].includes(probe.applied || ""), `${label} 应应用 delta/resync,实际: ${probe.applied}`);
|
||||
assert(["delta", "resync"].includes(probe.kind || ""), `${label} 应捕获匹配 rename 的 delta/resync 事件,实际: ${probe.kind}`);
|
||||
return probe.kind || probe.applied;
|
||||
}
|
||||
|
||||
async function readTreeSnapshot(workspaceId) {
|
||||
const response = await fetchWithTimeout(
|
||||
`${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(workspaceId)}&maxPolls=0`,
|
||||
@@ -120,6 +179,11 @@ async function main() {
|
||||
assert.equal(await activeChildRow.getAttribute("data-active"), "true", "点击 child row 后 active 标记未切换");
|
||||
|
||||
const renamedTitle = `${childTitle}-renamed`;
|
||||
await armTreeLiveProbe(page);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.removeAttribute("data-mnote-tree-live-applied");
|
||||
window.__task120TreeLiveEvents = [];
|
||||
});
|
||||
await postTreeCommand(
|
||||
{
|
||||
action: "rename",
|
||||
@@ -129,6 +193,9 @@ async function main() {
|
||||
},
|
||||
"重命名临时子页面",
|
||||
);
|
||||
const liveApplied = await waitForTreeLiveApplied(page, "重命名后 tree live", renamedTitle);
|
||||
const renamedLiveRow = await waitForSidebarRow(page, childPage.documentId);
|
||||
await expectRowTitle(renamedLiveRow, renamedTitle, `tree live ${liveApplied} 后 child row 标题`);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const renamedRow = await waitForSidebarRow(page, childPage.documentId);
|
||||
await expectRowTitle(renamedRow, renamedTitle, "重命名后 child row 标题");
|
||||
|
||||
@@ -2,33 +2,85 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
|
||||
async function fetchText(path) {
|
||||
const response = await fetch(`${BASE_URL}${path}`);
|
||||
const response = await fetchWithTimeout(`${BASE_URL}${path}`);
|
||||
const text = await response.text();
|
||||
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||
assert(result, `${label} 缺少 result`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createTempPage(title) {
|
||||
const result = await postTreeCommand({ action: "create", title }, `创建临时页面 ${title}`);
|
||||
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
|
||||
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
|
||||
return {
|
||||
documentId: result.documentId,
|
||||
workspaceId: result.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
async function purgeTempPage(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand(
|
||||
{
|
||||
action: "purge",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
},
|
||||
`清理临时页面 ${target.documentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const page = await fetchText("/documents/doc_1?workspaceId=ws_demo");
|
||||
assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/);
|
||||
assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/);
|
||||
assert.match(page.text, /\/api\/tree\/events/);
|
||||
assert.match(page.text, /tree:snapshot/);
|
||||
assert.match(page.text, /tree:delta/);
|
||||
assert.match(page.text, /tree:resync/);
|
||||
const pageTarget = await createTempPage(`task123-live-${Date.now().toString(36)}`);
|
||||
try {
|
||||
const page = await fetchText(`/documents/${encodeURIComponent(pageTarget.documentId)}?workspaceId=${encodeURIComponent(pageTarget.workspaceId)}`);
|
||||
assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/);
|
||||
assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/);
|
||||
assert.match(page.text, /\/api\/tree\/events/);
|
||||
assert.match(page.text, /tree:snapshot/);
|
||||
assert.match(page.text, /tree:delta/);
|
||||
assert.match(page.text, /tree:resync/);
|
||||
|
||||
const events = await fetchText("/api/tree/events?workspaceId=ws_demo&maxPolls=0");
|
||||
assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/);
|
||||
assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web");
|
||||
assert.match(events.text, /event:\s*snapshot/);
|
||||
assert.match(events.text, /id:\s*/);
|
||||
assert.match(events.text, /"revision"/);
|
||||
const events = await fetchText(`/api/tree/events?workspaceId=${encodeURIComponent(pageTarget.workspaceId)}&maxPolls=0`);
|
||||
assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/);
|
||||
assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web");
|
||||
assert.match(events.text, /event:\s*snapshot/);
|
||||
assert.match(events.text, /id:\s*/);
|
||||
assert.match(events.text, /"revision"/);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events" }, null, 2));
|
||||
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events", documentId: pageTarget.documentId }, null, 2));
|
||||
} finally {
|
||||
await purgeTempPage(pageTarget).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -19,8 +19,8 @@ function fileUrl(localPath) {
|
||||
}
|
||||
|
||||
function treeUrl(root, mode) {
|
||||
const url = new URL(`${BASE_URL}/tree`);
|
||||
url.searchParams.set("mode", mode);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("treeView", mode);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
@@ -34,8 +34,8 @@ function documentUrl(root, documentId) {
|
||||
}
|
||||
|
||||
function convexTreeUrl(mode = "filetree") {
|
||||
const url = new URL(`${BASE_URL}/tree`);
|
||||
url.searchParams.set("mode", mode);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("treeView", mode);
|
||||
url.searchParams.set("workspaceId", "ws_demo");
|
||||
url.searchParams.set("sourceKind", "convex_workspace");
|
||||
return url.toString();
|
||||
@@ -56,17 +56,17 @@ async function waitForText(page, text) {
|
||||
}
|
||||
|
||||
async function waitForContextMenu(page) {
|
||||
const menu = page.locator('[data-testid="filetree-context-menu"]').first();
|
||||
const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function clickMenuAction(page, action) {
|
||||
await page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function expectMenuAction(page, action, options = {}) {
|
||||
const item = page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).first();
|
||||
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const disabled = await item.isDisabled();
|
||||
if (options.disabled !== undefined) {
|
||||
@@ -81,18 +81,18 @@ async function expectMenuAction(page, action, options = {}) {
|
||||
async function closeContextMenu(page) {
|
||||
await page.keyboard.press("Escape");
|
||||
try {
|
||||
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
|
||||
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
|
||||
} catch {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('[data-testid="filetree-context-menu"]').forEach((element) => element.remove());
|
||||
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
|
||||
});
|
||||
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchRootContextMenu(page) {
|
||||
await page.evaluate(() => {
|
||||
const root = document.querySelector(".tree-root");
|
||||
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
|
||||
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
|
||||
root.dispatchEvent(new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
@@ -238,7 +238,7 @@ async function run() {
|
||||
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "docs", "blocks.md"),
|
||||
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph\n\n- Bullet item\n1. Numbered item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n",
|
||||
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph with `inline code`, **bold**, *italic*, ~~strike~~, and [Link](https://example.com).\n\n- Bullet item\n1. Numbered item\n- [ ] Unchecked item\n- [x] Checked item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n\n| Name | Value |\n| --- | --- |\n| Mark | `cell` |\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
|
||||
@@ -257,7 +257,12 @@ async function run() {
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/tree/commands")) {
|
||||
if (
|
||||
request.url().includes("/api/tree/commands") ||
|
||||
request.url().includes("/api/documents/title") ||
|
||||
request.url().includes("/api/documents/save") ||
|
||||
request.url().includes("/api/documents/options")
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
@@ -381,6 +386,52 @@ async function run() {
|
||||
await waitForText(page, "Quote item");
|
||||
await waitForText(page, "console.log('hi')");
|
||||
await waitForText(page, "Spec");
|
||||
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const checkboxStates = await page.locator(".ProseMirror input[type='checkbox']").evaluateAll((nodes) =>
|
||||
nodes.map((node) => Boolean(node.checked))
|
||||
);
|
||||
assert(checkboxStates.length === 2, `本地 Markdown 应渲染两个 checkbox,实际 ${checkboxStates.length}`);
|
||||
assert(checkboxStates.includes(true), "本地 Markdown 应渲染 checked checkbox");
|
||||
assert(checkboxStates.includes(false), "本地 Markdown 应渲染 unchecked checkbox");
|
||||
assert(await page.locator(".ProseMirror p code", { hasText: "inline code" }).count() >= 1, "inline code 应渲染为 code mark");
|
||||
assert(await page.locator(".ProseMirror strong", { hasText: "bold" }).count() >= 1, "strong 应渲染为 strong 标签");
|
||||
assert(await page.locator(".ProseMirror em", { hasText: "italic" }).count() >= 1, "em 应渲染为 em 标签");
|
||||
assert(await page.locator(".ProseMirror s, .ProseMirror del", { hasText: "strike" }).count() >= 1, "strike 应渲染为删除线标签");
|
||||
const linkTexts = await page.locator(".ProseMirror a").evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() || ""));
|
||||
assert(linkTexts.includes("Link"), `普通链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
|
||||
assert(linkTexts.includes("Spec"), `附件链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
|
||||
assert(await page.locator(".ProseMirror table td code", { hasText: "cell" }).count() >= 1, "table cell inline code 应保留 code mark");
|
||||
|
||||
if (process.env.MNOTE_TASK163_RUN_EXTENDED_TREE_OPS !== "1") {
|
||||
const filetreeRows = await page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
rowId: node.getAttribute("data-row-id") || "",
|
||||
rowKind: node.getAttribute("data-row-kind") || "",
|
||||
}))
|
||||
);
|
||||
assert(filetreeRows.some((row) => row.rowId === "local:folder:docs" && row.rowKind === "folder"), "当前主入口下的 filetree 容器应继续渲染 docs folder 行");
|
||||
assert(filetreeRows.some((row) => row.rowId === "local:markdown:README.md" && row.rowKind === "markdown"), "当前主入口下的 filetree 容器应继续渲染 README markdown 行");
|
||||
const pageAggregateResult = await page.evaluate(async ({ rootUri }) => {
|
||||
const response = await fetch(`/api/page-aggregate/${encodeURIComponent("local-md:docs~2Fblocks.md")}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
payload,
|
||||
};
|
||||
}, { rootUri: fileUrl(root) });
|
||||
assert(pageAggregateResult.ok, `page aggregate 路由应返回 200,实际 ${pageAggregateResult.status}`);
|
||||
assert(pageAggregateResult.payload?.schema === "mnote.page_aggregate.v1", `page aggregate schema 应保持稳定,实际 ${JSON.stringify(pageAggregateResult.payload)}`);
|
||||
assert(pageAggregateResult.payload?.result?.title === "Complex Title", "page aggregate 应继续返回本地 Markdown 标题");
|
||||
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "本地 Markdown 保存写操作必须透传 sourceKind=local_folder");
|
||||
await browser.close();
|
||||
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log("task163 local markdown parser browser smoke passed");
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
|
||||
@@ -569,7 +620,7 @@ async function run() {
|
||||
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
|
||||
await closeContextMenu(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.querySelector(".tree-root");
|
||||
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
|
||||
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
|
||||
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
@@ -17,10 +17,12 @@ function fileUrl(filePath) {
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-"));
|
||||
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main 本地 #other-"));
|
||||
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# Root Page\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8");
|
||||
fs.writeFileSync(path.join(otherRoot, "OTHER.md"), "# Other Root\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
@@ -54,6 +56,11 @@ async function main() {
|
||||
url.searchParams.get("rootUri") === fileUrl(root) &&
|
||||
url.searchParams.get("treeView") === "filetree";
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
const recentLocalRoots = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
|
||||
return JSON.parse(raw);
|
||||
});
|
||||
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
|
||||
|
||||
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!bodyText.includes("legacy_next_compat_disabled"), "本地文件夹主入口不能落入 legacy Next fallback");
|
||||
@@ -83,10 +90,57 @@ async function main() {
|
||||
`Shift+Click 应形成文件树范围多选,实际选中: ${selectedRowIds.join(", ")}`,
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`[data-testid="mnote-recent-local-root"][data-root-uri="${fileUrl(root)}"]`).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(otherRoot);
|
||||
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname === "/" &&
|
||||
url.searchParams.get("sourceKind") === "local_folder" &&
|
||||
url.searchParams.get("rootUri") === fileUrl(otherRoot) &&
|
||||
url.searchParams.get("treeView") === "filetree";
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
const updatedRecentLocalRoots = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
|
||||
return JSON.parse(raw);
|
||||
});
|
||||
assert.equal(updatedRecentLocalRoots[0], fileUrl(otherRoot), "切到其他本地文件夹后应把它放到最近目录首位");
|
||||
assert.equal(updatedRecentLocalRoots[1], fileUrl(root), "之前打开过的本地目录应保留在最近目录列表中");
|
||||
await page.locator('.tree-row[data-row-id="local:markdown:OTHER.md"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const recentPathInputValue = await page.locator('[data-testid="mnote-local-folder-path-input"]').inputValue({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(recentPathInputValue, otherRoot, "最近目录回填输入框时应解码 file:// rootUri,避免二次编码");
|
||||
await page.getByRole("button", { name: "取消" }).click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-switch-cloud-workspace"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname === "/" &&
|
||||
url.searchParams.get("sourceKind") === "convex_workspace" &&
|
||||
!url.searchParams.has("rootUri");
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
console.log("task164 desktop hot local folder main entry smoke passed");
|
||||
} finally {
|
||||
await browser.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(otherRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: vi.fn(async () => ({
|
||||
userId: "user_1",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(async () => ({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
query: vi.fn(async () => ({
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "Next fallback 页面",
|
||||
updated_at: null,
|
||||
can_edit: true,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeQueryPlan: vi.fn(async () => ({
|
||||
kind: "query",
|
||||
queryName: "documents.content.get",
|
||||
functionName: "documents:getContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_next",
|
||||
traceId: "trace_next",
|
||||
actorId: "user_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: { id: "doc_1" },
|
||||
})),
|
||||
executeRustBridgeQueryTransport: vi.fn(async () => ({
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
revision: 1,
|
||||
conflict_detection_key: "doc_1:1",
|
||||
pageSubtree: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/mnote-web/internal-url", () => ({
|
||||
resolveMnoteWebInternalUrl: vi.fn(async () => "http://127.0.0.1:3104"),
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/documents/page/route";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
|
||||
|
||||
function rustPageAggregateSnapshot() {
|
||||
return {
|
||||
schema: "mnote.page_aggregate.v1" as const,
|
||||
projectionVersion: 1,
|
||||
source: "KernelProjection",
|
||||
identity: { documentId: "doc_1", workspaceId: "ws_1" },
|
||||
head: {
|
||||
title: "Rust 聚合页面",
|
||||
updatedAt: null,
|
||||
permissions: {
|
||||
readOnly: false,
|
||||
disableDownload: false,
|
||||
disableCopy: false,
|
||||
},
|
||||
},
|
||||
layout: { pageOptions: { wideLayout: false } },
|
||||
body: { content: [], revision: 7, conflictDetectionKey: "doc_1:7" },
|
||||
tree: { pageSubtree: null },
|
||||
stats: { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("documents/page route", () => {
|
||||
it("优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_aggregate.v1",
|
||||
result: rustPageAggregateSnapshot(),
|
||||
requestId: "req_rust",
|
||||
traceId: "trace_rust",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"authorization": "Bearer route-token",
|
||||
"cookie": "convex-auth=test-cookie",
|
||||
"x-request-id": "req_route",
|
||||
"x-trace-id": "trace_route",
|
||||
"x-session-id": "sess_route",
|
||||
"x-source-channel": "next-route",
|
||||
"x-source-client": "vitest",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; identity: { documentId: string }; head: { title: string } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveMnoteWebInternalUrl).toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3104/api/page-aggregate/doc_1?workspaceId=ws_1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
|
||||
expect(fetchHeaders.get("accept")).toBe("application/json");
|
||||
expect(fetchHeaders.get("authorization")).toBe("Bearer route-token");
|
||||
expect(fetchHeaders.get("cookie")).toBe("convex-auth=test-cookie");
|
||||
expect(fetchHeaders.get("x-request-id")).toBe("req_route");
|
||||
expect(fetchHeaders.get("x-mnote-request-id")).toBe("req_route");
|
||||
expect(fetchHeaders.get("x-trace-id")).toBe("trace_route");
|
||||
expect(fetchHeaders.get("x-mnote-trace-id")).toBe("trace_route");
|
||||
expect(fetchHeaders.get("x-session-id")).toBe("sess_route");
|
||||
expect(fetchHeaders.get("x-mnote-session-id")).toBe("sess_route");
|
||||
expect(fetchHeaders.get("x-source-channel")).toBe("next-route");
|
||||
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next-route");
|
||||
expect(fetchHeaders.get("x-source-client")).toBe("vitest");
|
||||
expect(fetchHeaders.get("x-mnote-source-client")).toBe("vitest");
|
||||
expect(getAuthedConvexClient).not.toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
|
||||
expect(payload.page.identity.documentId).toBe("doc_1");
|
||||
expect(payload.page.head.title).toBe("Rust 聚合页面");
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_rust",
|
||||
traceId: "trace_rust",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
|
||||
it("Rust snapshot 返回畸形 projection 时保留 TS builder fallback", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_aggregate.v1",
|
||||
result: {
|
||||
schema: "mnote.page_aggregate.v1",
|
||||
projectionVersion: 1,
|
||||
source: "KernelProjection",
|
||||
identity: { documentId: "doc_1", workspaceId: "ws_1" },
|
||||
head: { title: "错误页面", updatedAt: null },
|
||||
layout: {},
|
||||
body: { content: [], revision: "bad_revision", conflictDetectionKey: 7 },
|
||||
tree: { pageSubtree: null },
|
||||
stats: null,
|
||||
},
|
||||
requestId: "req_bad",
|
||||
traceId: "trace_bad",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"x-request-id": "req_next",
|
||||
"x-trace-id": "trace_next",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; head: { title: string }; body: { revision: number | null } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getAuthedConvexClient).toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBeUndefined();
|
||||
expect(payload.page.head.title).toBe("Next fallback 页面");
|
||||
expect(payload.page.body.revision).toBe(1);
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_next",
|
||||
traceId: "trace_next",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
|
||||
it("Rust internal base 不可信时直接 fallback,且不会外发凭据", async () => {
|
||||
vi.mocked(resolveMnoteWebInternalUrl).mockResolvedValueOnce("https://example.com");
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"authorization": "Bearer route-token",
|
||||
"cookie": "convex-auth=test-cookie",
|
||||
"x-request-id": "req_untrusted",
|
||||
"x-trace-id": "trace_untrusted",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; head: { title: string } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getAuthedConvexClient).toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBeUndefined();
|
||||
expect(payload.page.head.title).toBe("Next fallback 页面");
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_untrusted",
|
||||
traceId: "trace_untrusted",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
|
||||
it("Rust snapshot fetch 抛错时保留 TS builder fallback", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"x-request-id": "req_timeout",
|
||||
"x-trace-id": "trace_timeout",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; head: { title: string } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getAuthedConvexClient).toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBeUndefined();
|
||||
expect(payload.page.head.title).toBe("Next fallback 页面");
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_timeout",
|
||||
traceId: "trace_timeout",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: vi.fn(() => true),
|
||||
@@ -28,10 +28,8 @@ vi.mock("@/lib/api-utils", () => ({
|
||||
|
||||
vi.mock("@/lib/documents/page-command-adapter", () => ({
|
||||
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
|
||||
@@ -72,18 +70,11 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
|
||||
}));
|
||||
|
||||
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
|
||||
import { POST as postDelete } from "@/app/api/documents/delete/route";
|
||||
import { POST as postEmbed } from "@/app/api/documents/embed/route";
|
||||
import { POST as postTemplate } from "@/app/api/documents/template/route";
|
||||
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
|
||||
import { POST as postPurge } from "@/app/api/documents/purge/route";
|
||||
import { POST as postRestore } from "@/app/api/documents/restore/route";
|
||||
import { POST as postTitle } from "@/app/api/documents/title/route";
|
||||
import { POST as postOptions } from "@/app/api/documents/options/route";
|
||||
import { POST as postSave } from "@/app/api/documents/save/route";
|
||||
import { POST as postCopyTree } from "@/app/api/documents/copy-tree/route";
|
||||
import { POST as postCreate } from "@/app/api/documents/create/route";
|
||||
import { POST as postMove } from "@/app/api/documents/move/route";
|
||||
import { GET as getPage } from "@/app/api/documents/page/route";
|
||||
import {
|
||||
executeDocumentCreateChildBridgeCommand,
|
||||
@@ -93,107 +84,12 @@ import {
|
||||
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
|
||||
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("documents route adapters", () => {
|
||||
it("create route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_create_1",
|
||||
traceId: "trace_tree_create_1",
|
||||
result: {
|
||||
action: "create",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_new",
|
||||
parentId: null,
|
||||
title: "无标题",
|
||||
sortOrder: 0,
|
||||
updatedAt: "2026-04-23T00:00:00Z",
|
||||
execution: {
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-23T00:00:00Z",
|
||||
updated_at: "2026-04-23T00:00:00Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postCreate(new Request("http://localhost/api/documents/create", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ parentId: null }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "create",
|
||||
parentId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.id).toBe("doc_new");
|
||||
expect(payload.workspace_id).toBe("ws_1");
|
||||
expect(payload.meta.commandName).toBe("tree.node.create");
|
||||
});
|
||||
|
||||
it("move route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_move_1",
|
||||
traceId: "trace_tree_move_1",
|
||||
result: {
|
||||
action: "move",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-23T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postMove(new Request("http://localhost/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1", parentId: "parent_1", position: 2.7, workspaceId: "ws_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
ok: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "move",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.subtree.move");
|
||||
});
|
||||
|
||||
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
@@ -260,117 +156,6 @@ describe("documents route adapters", () => {
|
||||
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_archive_1",
|
||||
traceId: "trace_tree_archive_1",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
success: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "archive",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.archive");
|
||||
});
|
||||
|
||||
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_restore_1",
|
||||
traceId: "trace_tree_restore_1",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
success: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "restore",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.restore");
|
||||
});
|
||||
|
||||
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_embed_1",
|
||||
traceId: "trace_tree_embed_1",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
ok: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "embed",
|
||||
sourceId: "doc_1",
|
||||
targetId: "doc_2",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.embed");
|
||||
});
|
||||
|
||||
it("template route delegates to unified adapter", async () => {
|
||||
await postTemplate(new Request("http://localhost/api/documents/template", {
|
||||
method: "POST",
|
||||
@@ -387,87 +172,6 @@ describe("documents route adapters", () => {
|
||||
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_purge_1",
|
||||
traceId: "trace_tree_purge_1",
|
||||
result: {
|
||||
purged: true,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
success: boolean;
|
||||
purged: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "purge",
|
||||
documentId: "doc_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.purged).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.purge");
|
||||
});
|
||||
|
||||
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_copy_1",
|
||||
traceId: "trace_tree_copy_1",
|
||||
result: {
|
||||
items: [{ oldId: "doc_1", newId: "doc_2" }],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "copy",
|
||||
targetParentId: null,
|
||||
items: [{ documentId: "doc_1", recursive: true }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
|
||||
expect(payload.meta.commandName).toBe("tree.subtree.copy");
|
||||
});
|
||||
|
||||
it("page route delegates to unified aggregate loader", async () => {
|
||||
const response = await getPage(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
buildDocumentQueryEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
|
||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import {
|
||||
buildPageAggregateFromDocumentPayloads,
|
||||
type DocumentContentPayload,
|
||||
type DocumentMetaPayload,
|
||||
} from "@/lib/documents/page-aggregate-builder";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
@@ -21,13 +21,20 @@ const FORWARDED_REQUEST_HEADERS = [
|
||||
"cookie",
|
||||
"authorization",
|
||||
"x-request-id",
|
||||
"x-mnote-request-id",
|
||||
"x-trace-id",
|
||||
"x-mnote-trace-id",
|
||||
"x-session-id",
|
||||
"x-mnote-session-id",
|
||||
"x-source-channel",
|
||||
"x-mnote-source-channel",
|
||||
"x-source-client",
|
||||
"x-mnote-source-client",
|
||||
"user-agent",
|
||||
] as const;
|
||||
|
||||
const RUST_PAGE_AGGREGATE_TIMEOUT_MS = 3_000;
|
||||
|
||||
export type LoadedPageAggregate = {
|
||||
page: PageAggregateProjection;
|
||||
bridge: {
|
||||
@@ -44,14 +51,187 @@ function copyForwardHeaderIfPresent(target: Headers, source: Headers, name: stri
|
||||
}
|
||||
}
|
||||
|
||||
function readHeaderAlias(source: Headers, ...names: string[]) {
|
||||
for (const name of names) {
|
||||
const value = source.get(name);
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function copyNormalizedHeader(target: Headers, source: Headers, targetName: string, ...sourceNames: string[]) {
|
||||
const value = readHeaderAlias(source, ...sourceNames);
|
||||
if (value) {
|
||||
target.set(targetName, value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardHeaders(source: Headers) {
|
||||
const requestHeaders = new Headers({
|
||||
accept: "application/json",
|
||||
});
|
||||
copyForwardHeaderIfPresent(requestHeaders, source, "cookie");
|
||||
copyForwardHeaderIfPresent(requestHeaders, source, "authorization");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-request-id", "x-request-id", "x-mnote-request-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-request-id", "x-mnote-request-id", "x-request-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-trace-id", "x-trace-id", "x-mnote-trace-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-trace-id", "x-mnote-trace-id", "x-trace-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-session-id", "x-session-id", "x-mnote-session-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-session-id", "x-mnote-session-id", "x-session-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-source-channel", "x-source-channel", "x-mnote-source-channel");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-source-channel", "x-mnote-source-channel", "x-source-channel");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-source-client", "x-source-client", "x-mnote-source-client");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-source-client", "x-mnote-source-client", "x-source-client");
|
||||
copyForwardHeaderIfPresent(requestHeaders, source, "user-agent");
|
||||
return requestHeaders;
|
||||
}
|
||||
|
||||
type RustPageAggregateSnapshotResponse = {
|
||||
ok?: boolean;
|
||||
schema?: string;
|
||||
result?: unknown;
|
||||
requestId?: string | null;
|
||||
traceId?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object";
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown) {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isNullableNumber(value: unknown) {
|
||||
return value === null || typeof value === "number";
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown) {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isTrustedMnoteWebInternalUrl(raw: string) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").trim().toLowerCase();
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname === "::"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRustPageAggregateProjection(
|
||||
value: unknown,
|
||||
input: { documentId: string; workspaceId?: string | null },
|
||||
): value is PageAggregateProjection {
|
||||
if (!isRecord(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const identity = isRecord(record.identity) ? record.identity : null;
|
||||
const head = isRecord(record.head) ? record.head : null;
|
||||
const permissions = head && isRecord(head.permissions) ? head.permissions : null;
|
||||
const layout = isRecord(record.layout) ? record.layout : null;
|
||||
const pageOptions = layout && isRecord(layout.pageOptions) ? layout.pageOptions : null;
|
||||
const body = isRecord(record.body) ? record.body : null;
|
||||
const tree = isRecord(record.tree) ? record.tree : null;
|
||||
const stats = isRecord(record.stats) ? record.stats : null;
|
||||
const requestedWorkspaceId = input.workspaceId?.trim() || null;
|
||||
return (
|
||||
record.schema === "mnote.page_aggregate.v1" &&
|
||||
typeof record.projectionVersion === "number" &&
|
||||
typeof record.source === "string" &&
|
||||
Boolean(identity) &&
|
||||
identity.documentId === input.documentId &&
|
||||
typeof identity.workspaceId === "string" &&
|
||||
(!requestedWorkspaceId || identity.workspaceId === requestedWorkspaceId) &&
|
||||
Boolean(head) &&
|
||||
typeof head.title === "string" &&
|
||||
isNullableString(head.updatedAt) &&
|
||||
Boolean(permissions) &&
|
||||
isBoolean(permissions.readOnly) &&
|
||||
isBoolean(permissions.disableDownload) &&
|
||||
isBoolean(permissions.disableCopy) &&
|
||||
Boolean(layout) &&
|
||||
Boolean(pageOptions) &&
|
||||
Boolean(body) &&
|
||||
"content" in body &&
|
||||
isNullableNumber(body.revision) &&
|
||||
isNullableString(body.conflictDetectionKey) &&
|
||||
Boolean(tree) &&
|
||||
Object.prototype.hasOwnProperty.call(tree, "pageSubtree") &&
|
||||
Boolean(stats) &&
|
||||
typeof stats.wordCount === "number" &&
|
||||
typeof stats.characterCount === "number" &&
|
||||
typeof stats.blockCount === "number" &&
|
||||
typeof stats.todoTotal === "number" &&
|
||||
typeof stats.todoDone === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadPageAggregateFromRustSnapshot(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<LoadedPageAggregate | null> {
|
||||
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||
if (!isTrustedMnoteWebInternalUrl(internalBaseUrl)) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(`/api/page-aggregate/${encodeURIComponent(input.documentId)}`, `${internalBaseUrl}/`);
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
if (workspaceId) {
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: buildForwardHeaders(input.request.headers),
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(RUST_PAGE_AGGREGATE_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null) as RustPageAggregateSnapshotResponse | null;
|
||||
const page = payload?.result;
|
||||
if (
|
||||
payload?.schema !== "mnote.page_aggregate.v1" ||
|
||||
!isRustPageAggregateProjection(page, input)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
bridge: {
|
||||
requestId: typeof payload.requestId === "string" && payload.requestId.trim() ? payload.requestId : "unknown",
|
||||
traceId: typeof payload.traceId === "string" && payload.traceId.trim() ? payload.traceId : "unknown",
|
||||
queryName: "documents.page.get",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildServerBridgeRequest(pathname: string): Promise<Request> {
|
||||
const headerList = await headers();
|
||||
const requestHeaders = new Headers();
|
||||
FORWARDED_REQUEST_HEADERS.forEach((name) => {
|
||||
copyForwardHeaderIfPresent(requestHeaders, headerList, name);
|
||||
});
|
||||
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||
|
||||
return new Request(`http://mnote.local${pathname}`, {
|
||||
return new Request(new URL(pathname, `${internalBaseUrl}/`).toString(), {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
@@ -86,6 +266,11 @@ export async function loadPageAggregate(input: {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<LoadedPageAggregate | null> {
|
||||
const rustSnapshot = await loadPageAggregateFromRustSnapshot(input);
|
||||
if (rustSnapshot) {
|
||||
return rustSnapshot;
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const meta = await client.query(api.documents.getMeta, {
|
||||
id: input.documentId,
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface PageAggregateTree {
|
||||
}
|
||||
|
||||
export interface PageAggregateProjection {
|
||||
schema?: "mnote.page_aggregate.v1";
|
||||
projectionVersion?: number;
|
||||
source?: "KernelProjection" | "CompatMetaContentJoin" | "Fixture" | string;
|
||||
identity: PageAggregateIdentity;
|
||||
head: PageAggregateHead;
|
||||
layout: PageAggregateLayout;
|
||||
|
||||
@@ -72,4 +72,151 @@ describe("tiptap-content-converter", () => {
|
||||
expect(roundTrip.blocks[4].props?.checked).toBe(true);
|
||||
expect(roundTrip.blocks[6].props?.language).toBe("ts");
|
||||
});
|
||||
|
||||
it("将 legacy media block 转成带链接的可见段落文本", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m1",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
marks: [{ type: "link", attrs: { href: "assets/spec.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 在 sourcePath 为空时使用 url 作为链接", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-url",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "", url: "assets/from-url.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m-url" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
marks: [{ type: "link", attrs: { href: "assets/from-url.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 在 name 为空时使用 fileName 作为可见文本", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-file-name",
|
||||
type: "media",
|
||||
props: { name: "", fileName: "Fallback Name", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m-file-name" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Fallback Name",
|
||||
marks: [{ type: "link", attrs: { href: "assets/spec.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 会继续 fallback 到 src 和 title", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-src-title",
|
||||
type: "media",
|
||||
props: {
|
||||
sourcePath: "",
|
||||
url: "",
|
||||
src: "assets/from-src.pdf",
|
||||
name: "",
|
||||
fileName: "",
|
||||
title: "Fallback Title",
|
||||
},
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m-src-title" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Fallback Title",
|
||||
marks: [{ type: "link", attrs: { href: "assets/from-src.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 的链接 mark 在 TipTap 往返和 legacy 输出中不丢失", () => {
|
||||
const tiptapDoc = tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-round-trip",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(tiptapDocFromEditorBlockDocument(editorBlockDocumentFromTiptapDoc(tiptapDoc))).toEqual(tiptapDoc);
|
||||
expect(blocksFromTiptapDoc(tiptapDoc)).toEqual([
|
||||
{
|
||||
id: "m-round-trip",
|
||||
type: "paragraph",
|
||||
props: undefined,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
styles: { link: "assets/spec.pdf" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TiptapDoc = {
|
||||
|
||||
export type EditorTextMark = "bold" | "italic" | "underline" | "strike" | "code";
|
||||
|
||||
export type EditorInlineMark = EditorTextMark | TiptapMark;
|
||||
|
||||
export type EditorBlockType =
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
@@ -34,7 +36,7 @@ export type EditorBlockType =
|
||||
export type EditorContentNode = {
|
||||
type: "text";
|
||||
text: string;
|
||||
marks?: EditorTextMark[];
|
||||
marks?: EditorInlineMark[];
|
||||
};
|
||||
|
||||
export type EditorBlock = {
|
||||
@@ -88,16 +90,62 @@ function coerceTextMark(value: unknown): EditorTextMark | null {
|
||||
return MARK_NAME_MAP[normalized] ?? null;
|
||||
}
|
||||
|
||||
function readMarks(marks: unknown): EditorTextMark[] {
|
||||
function uniqueInlineMarks(marks: EditorInlineMark[]): EditorInlineMark[] {
|
||||
const seen = new Set<string>();
|
||||
return marks.filter((mark) => {
|
||||
const key = typeof mark === "string" ? mark : `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function readMarks(marks: unknown): EditorInlineMark[] {
|
||||
if (!Array.isArray(marks)) return [];
|
||||
const normalized = marks
|
||||
.map((mark) => {
|
||||
if (typeof mark === "string") return coerceTextMark(mark);
|
||||
if (mark && typeof mark === "object") return coerceTextMark((mark as { type?: unknown }).type);
|
||||
return null;
|
||||
})
|
||||
.filter((mark): mark is EditorTextMark => Boolean(mark));
|
||||
return Array.from(new Set(normalized));
|
||||
return uniqueInlineMarks(marks.flatMap((mark): EditorInlineMark[] => {
|
||||
if (typeof mark === "string") {
|
||||
const textMark = coerceTextMark(mark);
|
||||
return textMark ? [textMark] : [];
|
||||
}
|
||||
if (!mark || typeof mark !== "object") return [];
|
||||
const record = mark as { type?: unknown; attrs?: unknown };
|
||||
const textMark = coerceTextMark(record.type);
|
||||
if (textMark) return [textMark];
|
||||
const type = typeof record.type === "string" ? record.type.trim() : "";
|
||||
if (type !== "link") return [];
|
||||
const attrs = record.attrs && typeof record.attrs === "object" ? record.attrs as Record<string, unknown> : {};
|
||||
const href = typeof attrs.href === "string" ? attrs.href.trim() : "";
|
||||
return href ? [{ type: "link", attrs: { href } }] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
function readStyles(styles: unknown): EditorInlineMark[] {
|
||||
if (!styles || typeof styles !== "object") return [];
|
||||
const record = styles as Record<string, unknown>;
|
||||
const marks: EditorInlineMark[] = [];
|
||||
if (record.bold) marks.push("bold");
|
||||
if (record.italic) marks.push("italic");
|
||||
if (record.underline) marks.push("underline");
|
||||
if (record.strike || record.strikethrough) marks.push("strike");
|
||||
if (record.code || record.inlineCode) marks.push("code");
|
||||
const href = typeof record.link === "string" && record.link.trim()
|
||||
? record.link.trim()
|
||||
: typeof record.href === "string" && record.href.trim()
|
||||
? record.href.trim()
|
||||
: "";
|
||||
if (href) marks.push({ type: "link", attrs: { href } });
|
||||
return marks;
|
||||
}
|
||||
|
||||
function readInlineMarks(marks: unknown, styles: unknown): EditorInlineMark[] {
|
||||
return uniqueInlineMarks([...readMarks(marks), ...readStyles(styles)]);
|
||||
}
|
||||
|
||||
function toTiptapMark(mark: EditorInlineMark): TiptapMark | null {
|
||||
if (typeof mark === "string") return { type: mark };
|
||||
const type = typeof mark.type === "string" ? mark.type.trim() : "";
|
||||
if (!type) return null;
|
||||
return mark.attrs ? { type, attrs: mark.attrs } : { type };
|
||||
}
|
||||
|
||||
function toText(value: unknown): string {
|
||||
@@ -110,6 +158,14 @@ function toText(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
const text = toText(value).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clampHeadingLevel(value: unknown): number {
|
||||
const level = Number(value);
|
||||
return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;
|
||||
@@ -124,16 +180,16 @@ function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
|
||||
if (Array.isArray(input)) {
|
||||
const nodes = input.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };
|
||||
const record = item as { type?: unknown; text?: unknown; marks?: unknown; styles?: unknown; payload?: unknown };
|
||||
if (record.type === "text") {
|
||||
const text = toText(record.text);
|
||||
return text ? [{ type: "text" as const, text, marks: readMarks(record.marks) }] : [];
|
||||
return text ? [{ type: "text" as const, text, marks: readInlineMarks(record.marks, record.styles) }] : [];
|
||||
}
|
||||
if (record.payload && typeof record.payload === "object") {
|
||||
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };
|
||||
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown; styles?: unknown };
|
||||
if (payload.type === "text") {
|
||||
const text = toText(payload.text);
|
||||
return text ? [{ type: "text" as const, text, marks: readMarks(payload.marks) }] : [];
|
||||
return text ? [{ type: "text" as const, text, marks: readInlineMarks(payload.marks, payload.styles) }] : [];
|
||||
}
|
||||
}
|
||||
const text = toText(item);
|
||||
@@ -190,6 +246,25 @@ function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBloc
|
||||
contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
case "media": {
|
||||
const sourcePath = firstNonEmptyText(props.sourcePath, props.url, props.src);
|
||||
const name = firstNonEmptyText(props.name, props.fileName, props.title, sourcePath);
|
||||
return {
|
||||
blockId,
|
||||
blockType: "paragraph",
|
||||
props: {},
|
||||
contentNodes: name
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: name,
|
||||
marks: sourcePath ? [{ type: "link", attrs: { href: sourcePath } }] : [],
|
||||
},
|
||||
]
|
||||
: contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
case "paragraph":
|
||||
case "text":
|
||||
default:
|
||||
@@ -241,8 +316,8 @@ function textNodesToInline(contentNodes: EditorContentNode[] | undefined): Tipta
|
||||
const text = toText(node.text);
|
||||
if (!text) return [];
|
||||
const marks = (node.marks ?? [])
|
||||
.map((mark) => ({ type: mark }))
|
||||
.filter((mark) => Boolean(mark.type));
|
||||
.map(toTiptapMark)
|
||||
.filter((mark): mark is TiptapMark => Boolean(mark));
|
||||
return [{ type: "text", text, marks: marks.length > 0 ? marks : undefined }];
|
||||
});
|
||||
}
|
||||
@@ -476,6 +551,36 @@ function orderedBlocksForLegacy(document: EditorBlockDocument): EditorBlock[] {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function legacyStylesFromMarks(marks: EditorInlineMark[] | undefined): Record<string, boolean | string> | undefined {
|
||||
const styles: Record<string, boolean | string> = {};
|
||||
for (const mark of marks ?? []) {
|
||||
if (typeof mark === "string") {
|
||||
styles[mark] = true;
|
||||
continue;
|
||||
}
|
||||
if (mark.type === "link") {
|
||||
const href = typeof mark.attrs?.href === "string" ? mark.attrs.href.trim() : "";
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return Object.keys(styles).length ? styles : undefined;
|
||||
}
|
||||
|
||||
function legacyContentFromNodes(contentNodes: EditorContentNode[] | undefined): Json {
|
||||
const nodes = contentNodes ?? [];
|
||||
if (!nodes.some((node) => (node.marks ?? []).length > 0)) {
|
||||
return nodes.map((node) => node.text).join("");
|
||||
}
|
||||
return nodes.map((node) => {
|
||||
const styles = legacyStylesFromMarks(node.marks);
|
||||
return {
|
||||
type: "text",
|
||||
text: node.text,
|
||||
...(styles ? { styles } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocument): Json {
|
||||
return orderedBlocksForLegacy(document).map((block) => ({
|
||||
id: block.blockId,
|
||||
@@ -488,7 +593,7 @@ export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocumen
|
||||
: block.blockType === "code_block"
|
||||
? { language: block.props?.language ?? null }
|
||||
: undefined,
|
||||
content: (block.contentNodes ?? []).map((node) => node.text).join(""),
|
||||
content: legacyContentFromNodes(block.contentNodes),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user