fix local markdown attachment regressions
This commit is contained in:
@@ -64,6 +64,7 @@
|
|||||||
- 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑。
|
- 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑。
|
||||||
- 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约。
|
- 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约。
|
||||||
- `mnote-web` 的 `compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。
|
- `mnote-web` 的 `compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。
|
||||||
|
- 浏览器主链默认禁止新增基于 `setInterval`、周期 `setTimeout` 或轮询 fallback 的数据刷新、状态同步和资源存在性检查,尤其是页面树、文件树、文档页、附件、AI 面板等高频可见路径。优先使用 Rust realtime event stream、WebSocket/SSE、watcher、`tree:delta/resync`、`page-aggregate-synced`、`MutationObserver` 和显式 command result 驱动刷新;只有在明确标注的 legacy/debug/internal 边界下,才允许短期保留轮询,并且必须在设计/bug 说明里记录原因、退出条件和内存/CPU 风险。
|
||||||
- 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。
|
- 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。
|
||||||
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
|
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
|
||||||
- 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进。`mnote.page.save` 只作为页面级兜底写入工具。
|
- 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进。`mnote.page.save` 只作为页面级兜底写入工具。
|
||||||
@@ -118,6 +119,7 @@
|
|||||||
- 不要擅自恢复、覆盖、删除用户已有改动。
|
- 不要擅自恢复、覆盖、删除用户已有改动。
|
||||||
- 若发现与当前任务无关的脏改动,保持不动;若怀疑会影响当前任务,先确认再处理。
|
- 若发现与当前任务无关的脏改动,保持不动;若怀疑会影响当前任务,先确认再处理。
|
||||||
- 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。
|
- 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。
|
||||||
|
- 若发现现有轮询或定时刷新链路,不要直接在其上继续叠加补丁;先确认是否能改成事件驱动、watcher/realtime 推送或命令结果定点刷新,再决定是否保留临时 fallback。
|
||||||
- Reasonix / subagent 只能作为受控叶子 worker:任务书必须限定读写范围、验证命令、交付文件和禁止项;不得自行再启动 runner、subagent、额外 worktree 或修改任务书。Codex/Hermes 主控必须复核 diff、handoff/result、验证证据和 git 状态后才能采纳。
|
- Reasonix / subagent 只能作为受控叶子 worker:任务书必须限定读写范围、验证命令、交付文件和禁止项;不得自行再启动 runner、subagent、额外 worktree 或修改任务书。Codex/Hermes 主控必须复核 diff、handoff/result、验证证据和 git 状态后才能采纳。
|
||||||
|
|
||||||
## CodeGraph 使用
|
## CodeGraph 使用
|
||||||
|
|||||||
+83
-54
@@ -9,8 +9,8 @@
|
|||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- 把图片、PDF、Office、音视频、普通文件统一收口到标准 Markdown link/image 表达。
|
- 把图片、PDF、Office、音视频、普通文件统一收口到标准 Markdown link/image 表达。
|
||||||
- 上传时默认把文件复制到当前 `.md` 邻接资源目录,并在正文插入相对链接。
|
- 上传时默认把文件复制到当前 `.md` 所在同目录,并在正文插入相对链接。
|
||||||
- 用户手写或粘贴 `file://`、`https://`、相对路径、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`。
|
- 用户手写或粘贴同目录相对路径、`file://`、`https://`、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`。
|
||||||
- Tiptap 只负责编辑器节点渲染,不持有资源真相;点击打开交给 resource opener。
|
- Tiptap 只负责编辑器节点渲染,不持有资源真相;点击打开交给 resource opener。
|
||||||
- 文件树、resource tab、preview viewer、系统打开、授权检查都基于 `AttachmentRef` / `OpenTarget`,不从 DOM 字符串重复猜测。
|
- 文件树、resource tab、preview viewer、系统打开、授权检查都基于 `AttachmentRef` / `OpenTarget`,不从 DOM 字符串重复猜测。
|
||||||
|
|
||||||
@@ -28,34 +28,39 @@
|
|||||||
|
|
||||||
```md
|
```md
|
||||||
[同目录 PDF](./report.pdf)
|
[同目录 PDF](./report.pdf)
|
||||||
[页面附件](./dha_computational_modeling_literature.assets/model.pptx)
|
[页面附件](./model.pptx)
|
||||||

|

|
||||||
[外部本地文件](file:///mnt/Data1T/research/shared/reference.pdf)
|
[外部本地文件](file:///mnt/Data1T/research/shared/reference.pdf)
|
||||||
[网页资料](https://example.com/paper.pdf)
|
[网页资料](https://example.com/paper.pdf)
|
||||||
```
|
```
|
||||||
|
|
||||||
兼容输入:
|
支持输入:
|
||||||
|
|
||||||
- `./a.pdf`:相对当前 `.md` 所在目录解析。
|
- `./a.pdf`:相对当前 `.md` 所在目录解析。
|
||||||
- `../assets/a.pdf`:相对当前 `.md` 所在目录解析。
|
|
||||||
- `file:///mnt/Data1T/a.pdf`:按标准 file URI 解析,必须经过授权 root 检查。
|
- `file:///mnt/Data1T/a.pdf`:按标准 file URI 解析,必须经过授权 root 检查。
|
||||||
- `/mnt/Data1T/a.pdf`:作为兼容输入接受;保存时建议规范化为 `file:///mnt/Data1T/a.pdf`,或在同 root 内转换为相对路径。
|
- `/mnt/Data1T/a.pdf`:作为开发期兼容输入接受并解析为外部本地文件;本清单不要求保存时自动规范化为 `file://`,也不要求转换为相对路径。
|
||||||
- `https://...` / `http://...`:远程 URL,不进入本地文件授权,但进入 URL 安全策略。
|
- `https://...` / `http://...`:远程 URL,不进入本地文件授权,但进入 URL 安全策略。
|
||||||
|
|
||||||
|
明确不做:
|
||||||
|
|
||||||
|
- 当前仍处开发态,不做历史 Markdown 中旧 `/office-preview?...` href 的批量迁移、读取兼容迁移或保存时自动重写。
|
||||||
|
- 不兼容 `../assets/a.pdf` 这类上级目录相对路径;不解析、不生成、不迁移这类引用。跨目录本地附件请使用经过授权的 `file://` 或裸绝对路径输入。
|
||||||
|
|
||||||
默认上传插入规则:
|
默认上传插入规则:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
当前文档:/mnt/Data1T/research/DHA/literature/dha.md
|
当前文档:/mnt/Data1T/research/DHA/literature/dha.md
|
||||||
上传文件:/home/lix/Downloads/model.pptx
|
上传文件:/home/lix/Downloads/model.pptx
|
||||||
复制目标:/mnt/Data1T/research/DHA/literature/dha.assets/model-<stable-id>.pptx
|
复制目标:/mnt/Data1T/research/DHA/literature/model-<stable-id>.pptx
|
||||||
正文插入:[model.pptx](./dha.assets/model-<stable-id>.pptx)
|
正文插入:[model.pptx](./model-<stable-id>.pptx)
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 相对链接不是不唯一;内核解析时用当前 `.md` 路径补全,得到唯一绝对路径。
|
- 相对链接不是不唯一;内核解析时用当前 `.md` 路径补全,得到唯一绝对路径。
|
||||||
- 正文优先保存相对路径,是为了移动文件夹、同步、分享、VSCode/Sidex/Markdown 工具打开时保持可用。
|
- 正文优先保存相对路径,是为了移动文件夹、同步、分享、VSCode/Sidex/Markdown 工具打开时保持可用。
|
||||||
- 绝对路径作为外部引用保留,但默认不作为上传后的持久格式。
|
- 绝对路径作为外部引用保留;上传后的持久格式仍固定为当前 `.md` 同目录相对链接。
|
||||||
|
- 默认上传目录维持当前 MNote 行为:与当前 `.md` 同目录。`<mdStem>.assets/`、`assets/` 或用户自定义资源目录只作为未来可选整理策略,不作为本清单默认行为。
|
||||||
|
|
||||||
## 4. `AttachmentRef` 合同
|
## 4. `AttachmentRef` 合同
|
||||||
|
|
||||||
@@ -120,9 +125,9 @@ type OpenTarget = {
|
|||||||
|
|
||||||
1. 用户在 Tiptap/Markdown 位置发起上传。
|
1. 用户在 Tiptap/Markdown 位置发起上传。
|
||||||
2. 浏览器只持有临时 `UploadDraft`:文件名、进度、取消、错误。
|
2. 浏览器只持有临时 `UploadDraft`:文件名、进度、取消、错误。
|
||||||
3. Rust upload API 根据当前 `.md` 路径计算 page-local assets 目录。
|
3. Rust upload API 根据当前 `.md` 路径计算同目录上传目标。
|
||||||
4. 后端写入文件,进行文件名净化、冲突命名、可选 hash 去重。
|
4. 后端写入文件,进行文件名净化、冲突命名、可选 hash 去重。
|
||||||
5. API 返回稳定 Markdown href,如 `./dha.assets/model-<id>.pptx`。
|
5. API 返回稳定 Markdown href,如 `./model-<id>.pptx`。
|
||||||
6. Tiptap 将临时上传节点替换为标准 link/image 节点。
|
6. Tiptap 将临时上传节点替换为标准 link/image 节点。
|
||||||
7. 保存 Markdown 后,watcher / projection 重新解析出 `AttachmentRef`。
|
7. 保存 Markdown 后,watcher / projection 重新解析出 `AttachmentRef`。
|
||||||
|
|
||||||
@@ -351,75 +356,99 @@ MNote 借鉴点:上传结果返回稳定 href;文件名净化、冲突命名
|
|||||||
|
|
||||||
### Batch A:现状冻结与 RED 验证
|
### Batch A:现状冻结与 RED 验证
|
||||||
|
|
||||||
- [ ] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。
|
- [x] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。
|
||||||
- [ ] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。
|
- [x] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。
|
||||||
- [ ] 截图记录当前卡片、文件树、resource tab、preview 行为。
|
- [x] 截图记录当前卡片、文件树、resource tab、preview 行为。
|
||||||
|
|
||||||
### Batch B:Markdown href 解析合同
|
### Batch B:Markdown href 解析合同
|
||||||
|
|
||||||
- [ ] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。
|
- [x] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。
|
||||||
- [ ] 支持解析 Markdown image/link 的 `rawHref`、label、source range。
|
- [x] 支持解析 Markdown image/link 的 `rawHref`、label、source range。
|
||||||
- [ ] 支持相对路径、`file://`、`http(s)`、兼容裸绝对路径。
|
- [x] 支持同目录相对路径、`file://`、`http(s)`、兼容裸绝对路径;不兼容 `../assets/a.pdf`。
|
||||||
- [ ] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。
|
- [x] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。
|
||||||
|
|
||||||
### Batch C:授权与 root 解析
|
### Batch C:授权与 root 解析
|
||||||
|
|
||||||
- [ ] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。
|
- [x] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。
|
||||||
- [ ] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。
|
- [x] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。
|
||||||
- [ ] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`。
|
- [x] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`。
|
||||||
- [ ] Rust 定点测试覆盖已授权、未授权、跨用户隔离。
|
- [x] Rust 定点测试覆盖已授权、未授权、跨用户隔离。
|
||||||
|
|
||||||
### Batch D:上传写入与 href 返回
|
### Batch D:上传写入与 href 返回
|
||||||
|
|
||||||
- [ ] local upload API 接收 owner document path,不再只返回 preview URL。
|
- [x] local upload API 接收 owner document path,不再只返回 preview URL。
|
||||||
- [ ] 默认写入 `<mdStem>.assets/`。
|
- [x] 默认写入当前 `.md` 所在同目录。
|
||||||
- [ ] 文件名净化、冲突命名、可选 hash 去重。
|
- [x] 文件名净化、冲突命名、可选 hash 去重。
|
||||||
- [ ] 返回 `{ displayName, markdownHref, attachmentRef }`。
|
- [x] 返回 `{ displayName, markdownHref, attachmentRef }`。
|
||||||
- [ ] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。
|
- [x] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。
|
||||||
|
|
||||||
### Batch E:Tiptap / Markdown 编辑器渲染
|
### Batch E:Tiptap / Markdown 编辑器渲染
|
||||||
|
|
||||||
- [ ] 上传中使用临时 `UploadDraft` / upload NodeView。
|
- [x] 上传中使用临时 `UploadDraft` / upload NodeView。
|
||||||
- [ ] 成功后替换为标准 link/image,不保存 preview URL。
|
- [x] 成功后替换为标准 link/image,不保存 preview URL。
|
||||||
- [ ] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。
|
- [x] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。
|
||||||
- [ ] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。
|
- [x] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。
|
||||||
- [ ] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。
|
- [x] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。
|
||||||
|
|
||||||
### Batch F:Resource opener 与 preview
|
### Batch F:Resource opener 与 preview
|
||||||
|
|
||||||
- [ ] 新增或收口 `openAttachment(ref)`。
|
- [x] 新增或收口 `openAttachment(ref)`。
|
||||||
- [ ] PDF/Office/image/text/download/system external 分发到 `OpenTarget`。
|
- [x] PDF/Office/image/text/download/system external 分发到 `OpenTarget`。
|
||||||
- [ ] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。
|
- [x] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。
|
||||||
- [ ] 缺失和未授权路径显示明确阻断状态。
|
- [x] 缺失和未授权路径显示明确阻断状态。
|
||||||
|
|
||||||
### Batch G:File Tree / watcher / missing asset
|
### Batch G:File Tree / watcher / missing asset
|
||||||
|
|
||||||
- [ ] 文件树从附件 projection 显示 page-local assets 目录和文件。
|
- [x] 文件树从附件 projection 显示当前 `.md` 同目录上传的附件文件。
|
||||||
- [ ] 上传后 watcher 刷新文件树,不依赖手动刷新。
|
- [x] 上传后 watcher 刷新文件树,不依赖手动刷新。
|
||||||
- [ ] 删除/移动附件后正文卡片显示 missing,不崩溃。
|
- [x] 删除/移动附件后正文卡片显示 missing,不崩溃。
|
||||||
- [ ] 外部新增同目录附件后,手写相对链接可解析并打开。
|
- [x] 外部新增同目录附件后,手写相对链接可解析并打开。
|
||||||
|
|
||||||
### Batch H:兼容迁移
|
### Batch H:开发态清理与非兼容边界(非迁移)
|
||||||
|
|
||||||
- [ ] 兼容旧 Markdown 中已有 `/office-preview?...path=...` href,读取时转换为 `AttachmentRef`,保存时迁移为标准 href。
|
- [x] 确认新写入 Markdown 不再产生 `/office-preview?...`、`/api/local-folder/files/open` 或 DOM 临时属性依赖。
|
||||||
- [ ] 兼容现有 `data-mnote-attachment-link` DOM 增强路径,但不作为主合同。
|
- [x] 开发态不做旧 `/office-preview?...` href 历史迁移,也不做读取旧 href 后自动重写保存的兼容逻辑;发现旧数据时按当前开发数据重建或手工修正处理。
|
||||||
- [ ] 补设计说明:旧路径只做读兼容,不再写入。
|
- [x] 不兼容 `../assets/a.pdf` 上级目录相对路径;不解析、不生成、不迁移这类引用,跨目录引用走授权 `file://` / 绝对路径。
|
||||||
|
- [x] 补设计说明:旧路径和 `data-mnote-attachment-link` 只作为开发期遗留现象排查,不作为本清单验收项。
|
||||||
|
|
||||||
### Batch I:验证矩阵
|
### Batch I:验证矩阵
|
||||||
|
|
||||||
- [ ] Rust:AttachmentRef parse/resolve/auth/open target 单测。
|
- [x] Rust:AttachmentRef parse/resolve/auth/open target 单测。
|
||||||
- [ ] JS:Tiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。
|
- [x] JS:Tiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。
|
||||||
- [ ] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。
|
- [x] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。
|
||||||
- [ ] Browser:VSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。
|
- [x] Browser:VSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。
|
||||||
- [ ] Browser:未授权 `file://` 阻断、授权后可打开。
|
- [x] Browser:未授权 `file://` 阻断、授权后可打开。
|
||||||
- [ ] `git diff --check`。
|
- [x] `git diff --check`。
|
||||||
- [ ] 涉及代码图修改后运行 `codegraph sync .`。
|
- [x] 涉及代码图修改后运行 `codegraph sync .`。
|
||||||
|
|
||||||
## 10. 验收标准
|
## 10. 执行证据(2026-05-28)
|
||||||
|
|
||||||
|
- Rust:`cargo test -p mnote-web markdown_attachment_refs --manifest-path rust/Cargo.toml` 通过,覆盖标准 link/image、`file://`、http(s)、裸绝对路径、中文/空格/百分号、缺失文件、`../assets` unknown。
|
||||||
|
- Rust:`cargo test -p mnote-web local_markdown_asset_upload_copies_next_to_markdown_with_relative_path --manifest-path rust/Cargo.toml` 通过,确认上传写入当前 `.md` 同目录。
|
||||||
|
- Rust:`cargo test -p mnote-web local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants --manifest-path rust/Cargo.toml` 通过,覆盖 SQLite allowed roots 与跨用户隔离。
|
||||||
|
- Rust:`cargo test -p mnote-web local_markdown_save_does_not_migrate_runtime_open_url_inline_link --manifest-path rust/Cargo.toml`、`cargo test -p mnote-web local_markdown_save_does_not_migrate_relative_runtime_open_url --manifest-path rust/Cargo.toml` 通过,确认开发态不做旧 runtime URL 自动迁移。
|
||||||
|
- JS:`node --check` 通过 `sidebar-attachment-open-runtime.js`、`local-upload-runtime.js`、`sidebar-tree-runtime.js`、`document-tiptap-conversion-runtime.js`、`document-editor-adapter-runtime.js`、`task506-local-markdown-attachment-ref-matrix-smoke.js`。
|
||||||
|
- Browser:`MNOTE_SMOKE_UI_TIMEOUT_MS=60000 node scripts/task503-local-pptx-upload-filetree-open-smoke.js` 通过,证据:`tmp/task503-local-pptx-upload-filetree-open-smoke/result.json`。
|
||||||
|
- Browser:`MNOTE_SMOKE_UI_TIMEOUT_MS=60000 node scripts/task506-local-markdown-attachment-ref-matrix-smoke.js` 通过,证据:`tmp/task506-local-markdown-attachment-ref-matrix-smoke/result.json`;覆盖 PDF/PNG/DOCX 上传、授权外部 file、未授权 file 阻断、`../assets` unknown、缺失附件阻断、Markdown 原文标准 link/image。
|
||||||
|
- 收尾:`git diff --check` 通过;`codegraph sync .` 通过。
|
||||||
|
|
||||||
|
剩余风险:missing/unauthorized 的可见样式以 Tiptap link mark 中的稳定 class 为主;`data-mnote-attachment-*` 只作为 runtime 补偿属性,不作为持久合同。已用 `task506` 验证无需显式 flush 的自动 missing 显示、点击阻断和截图证据。附件增强链不得引入周期刷新;当前已移除 `setInterval` 与启动延迟轮询,只保留 MutationObserver、page aggregate synced、tree delta/resync 等事件驱动入口。
|
||||||
|
|
||||||
|
## 10.1 回归修复证据(2026-05-29)
|
||||||
|
|
||||||
|
- Root cause:中文 bundle 页面刷新前的附件 fallback 曾只把 `local-md:` 中的 `~2F` 解为 `/`,未完整解码 `~E6...` UTF-8 字节,导致 `./a.pdf` 被拼成 `~E6.../a.pdf` 并请求 `/api/local-folder/files/open` 返回 400。已改为 `~XX -> %XX -> decodeURIComponent`。
|
||||||
|
- Root cause:新建页面和点击 `.md` 行时,filetree 选择逻辑会先命中带同一 `documentId` 的 bundle folder,导致 selected/focused/active 落父文件夹。已改为优先消费 `selectTarget.rowId/relativePath` 并通过 `revealFileTreeResource` 展开、聚焦真实 Markdown 行,只有 reveal 失败才退回父文件夹。
|
||||||
|
- Browser:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,覆盖新建页面后 bundle 展开,内部 `.md` 行 selected/focused/active。
|
||||||
|
- Browser:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:45231 node scripts/task506-local-markdown-attachment-ref-matrix-smoke.js` 通过,覆盖中文页面下 PDF 上传后不刷新立即点击打开、文件树显示同目录附件、授权/未授权 file、missing 阻断;截图 `tmp/task506-local-markdown-attachment-ref-matrix-smoke/00-uploaded-pdf-open-before-reload.png`。
|
||||||
|
- JS:`node --check` 通过 `sidebar-attachment-open-runtime.js`、`sidebar-filetree-command-runtime.js`、`sidebar-page-tree-runtime.js`、`task494-filetree-lazy-loading-dedup-smoke.js`、`task506-local-markdown-attachment-ref-matrix-smoke.js`。
|
||||||
|
- Rust:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_create_page_creates_timestamped_nested_bundle -- --nocapture` 通过。
|
||||||
|
- 收尾:`git diff --check` 通过;`codegraph sync .` 通过。`codegraph status .` 仍显示 `Pending Changes: Added: 1 files`,属于当前工作区 5-34 文件移动/未跟踪状态遗留,非本轮 runtime 修复失败。
|
||||||
|
|
||||||
|
## 11. 验收标准
|
||||||
|
|
||||||
- 任意时刻上传附件,正文只产生标准 Markdown href,不产生运行时 preview URL。
|
- 任意时刻上传附件,正文只产生标准 Markdown href,不产生运行时 preview URL。
|
||||||
- 第一个、第二个、第三个附件保存重渲染后都能点击并打开。
|
- 第一个、第二个、第三个附件保存重渲染后都能点击并打开。
|
||||||
- 文件树能显示 page-local 附件,且 watcher 自动刷新。
|
- 文件树能显示当前 `.md` 同目录上传的附件,且 watcher 自动刷新。
|
||||||
- 复制整个本地文件夹到新位置后,相对附件仍可解析。
|
- 复制整个本地文件夹到新位置后,相对附件仍可解析。
|
||||||
- `file://` 外部引用在授权 root 内可打开,未授权时阻断。
|
- `file://` 外部引用在授权 root 内可打开,未授权时阻断。
|
||||||
- Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。
|
- Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。
|
||||||
@@ -5978,6 +5978,7 @@ fn build_page_aggregate_projection_result(
|
|||||||
block_document,
|
block_document,
|
||||||
block_projection_version,
|
block_projection_version,
|
||||||
projection_source,
|
projection_source,
|
||||||
|
attachment_refs: Value::Array(Vec::new()),
|
||||||
},
|
},
|
||||||
tree: PageTree { page_subtree },
|
tree: PageTree { page_subtree },
|
||||||
stats: PageStats {
|
stats: PageStats {
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
block_projection_version: 1,
|
block_projection_version: 1,
|
||||||
projection_source: "fixture".into(),
|
projection_source: "fixture".into(),
|
||||||
|
attachment_refs: serde_json::json!([]),
|
||||||
},
|
},
|
||||||
tree: page_aggregate::PageTree {
|
tree: page_aggregate::PageTree {
|
||||||
page_subtree: serde_json::json!({"rootNodeId": "page_1"}),
|
page_subtree: serde_json::json!({"rootNodeId": "page_1"}),
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ mod tests {
|
|||||||
assert_eq!(body.block_document, json!(null));
|
assert_eq!(body.block_document, json!(null));
|
||||||
assert_eq!(body.projection_source, "");
|
assert_eq!(body.projection_source, "");
|
||||||
assert_eq!(body.file_version, json!(null));
|
assert_eq!(body.file_version, json!(null));
|
||||||
|
assert_eq!(body.attachment_refs, json!(null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +160,8 @@ pub struct PageBody {
|
|||||||
pub block_projection_version: u32,
|
pub block_projection_version: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub projection_source: String,
|
pub projection_source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachment_refs: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ import {
|
|||||||
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
||||||
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
||||||
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
|
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
|
||||||
|
const DEV_HOT_BUSTER = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(import.meta.url).searchParams.get('devHot') || '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const withDevHot = (path) => {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
if (DEV_HOT_BUSTER) url.searchParams.set('devHot', DEV_HOT_BUSTER);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
const parseJsonScript = (id) => {
|
const parseJsonScript = (id) => {
|
||||||
const node = document.getElementById(id);
|
const node = document.getElementById(id);
|
||||||
@@ -66,12 +79,12 @@ import {
|
|||||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||||
}
|
}
|
||||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
|
const manifestResponse = await fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'));
|
||||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||||
const manifest = await manifestResponse.json();
|
const manifest = await manifestResponse.json();
|
||||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||||
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
|
const entryUrl = withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`);
|
||||||
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
|
const wasmUrl = manifest.wasmAssetPath ? withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`) : undefined;
|
||||||
const runtime = await import(entryUrl);
|
const runtime = await import(entryUrl);
|
||||||
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
||||||
throw new Error('island runtime 导出不完整');
|
throw new Error('island runtime 导出不完整');
|
||||||
@@ -204,6 +217,7 @@ import {
|
|||||||
window.dispatchEvent(new CustomEvent('mnote:page-aggregate-synced', {
|
window.dispatchEvent(new CustomEvent('mnote:page-aggregate-synced', {
|
||||||
detail: { scriptId, documentId: aggregateDocumentId(nextAggregate) }
|
detail: { scriptId, documentId: aggregateDocumentId(nextAggregate) }
|
||||||
}));
|
}));
|
||||||
|
enhanceEditorAttachmentLinksSoon();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('mnote Page Aggregate script 同步失败', error);
|
console.warn('mnote Page Aggregate script 同步失败', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,27 +363,77 @@ export const mergeClassNames = (...values) => {
|
|||||||
|
|
||||||
export const localAttachmentClassForTiptapHref = (href) => {
|
export const localAttachmentClassForTiptapHref = (href) => {
|
||||||
const localFilePath = localFileOpenPathFromTiptapHref(href);
|
const localFilePath = localFileOpenPathFromTiptapHref(href);
|
||||||
return localFilePath ? attachmentClassForFileName(fileNameFromPath(localFilePath)) : '';
|
if (localFilePath) return attachmentClassForFileName(fileNameFromPath(localFilePath));
|
||||||
|
const value = String(href || '').trim();
|
||||||
|
if (!value || isExternalOrSpecialUrl(value)) return '';
|
||||||
|
if (value.startsWith('../') || value.includes('/../')) return '';
|
||||||
|
return attachmentClassForFileName(fileNameFromPath(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachmentRefsFromContext = (context) => {
|
||||||
|
if (Array.isArray(context?.attachmentRefs)) return context.attachmentRefs;
|
||||||
|
if (Array.isArray(context?.attachment_refs)) return context.attachment_refs;
|
||||||
|
if (Array.isArray(context?.body?.attachmentRefs)) return context.body.attachmentRefs;
|
||||||
|
if (Array.isArray(context?.body?.attachment_refs)) return context.body.attachment_refs;
|
||||||
|
if (Array.isArray(context?.latestAggregate?.body?.attachmentRefs)) return context.latestAggregate.body.attachmentRefs;
|
||||||
|
if (Array.isArray(context?.latestAggregate?.body?.attachment_refs)) return context.latestAggregate.body.attachment_refs;
|
||||||
|
if (Array.isArray(context?.aggregate?.body?.attachmentRefs)) return context.aggregate.body.attachmentRefs;
|
||||||
|
if (Array.isArray(context?.aggregate?.body?.attachment_refs)) return context.aggregate.body.attachment_refs;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachmentRefForTiptapHref = (href, context) => {
|
||||||
|
const value = String(href || '').trim();
|
||||||
|
if (!value) return null;
|
||||||
|
return attachmentRefsFromContext(context).find((ref) => (
|
||||||
|
ref && typeof ref === 'object' && (
|
||||||
|
String(ref.rawHref || '') === value
|
||||||
|
|| String(ref.normalizedHref || '') === value
|
||||||
|
|| String(ref.resolvedUri || '') === value
|
||||||
|
)
|
||||||
|
)) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withAttachmentProjectionAttrs = (attrs, attachmentRef) => {
|
||||||
|
if (!attachmentRef || typeof attachmentRef !== 'object') return attrs;
|
||||||
|
const next = { ...attrs };
|
||||||
|
if (attachmentRef.exists === false) {
|
||||||
|
next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-missing');
|
||||||
|
next['data-mnote-attachment-missing'] = 'true';
|
||||||
|
next['aria-label'] = `${String(attachmentRef.label || '附件')}(文件不存在)`;
|
||||||
|
}
|
||||||
|
if (attachmentRef.authorized === false) {
|
||||||
|
next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-unauthorized');
|
||||||
|
next['data-mnote-attachment-unauthorized'] = 'true';
|
||||||
|
next['aria-label'] = `${String(attachmentRef.label || '附件')}(无权访问)`;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const localizeTiptapAssetUrls = (node, context) => {
|
export const localizeTiptapAssetUrls = (node, context) => {
|
||||||
if (!node || typeof node !== 'object') return node;
|
if (!node || typeof node !== 'object') return node;
|
||||||
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
|
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
|
||||||
node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) };
|
const originalSrc = String(node.attrs.src || '').trim();
|
||||||
|
node.attrs = {
|
||||||
|
...node.attrs,
|
||||||
|
src: localFileOpenUrlForTiptap(originalSrc, context),
|
||||||
|
mnoteMarkdownSrc: node.attrs.mnoteMarkdownSrc || originalSrc,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (Array.isArray(node.marks)) {
|
if (Array.isArray(node.marks)) {
|
||||||
node.marks = node.marks.map((mark) => {
|
node.marks = node.marks.map((mark) => {
|
||||||
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
|
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
|
||||||
const href = localFileOpenUrlForTiptap(mark.attrs.href, context);
|
const href = String(mark.attrs.href || '').trim();
|
||||||
const attachmentClass = localAttachmentClassForTiptapHref(href);
|
const attachmentClass = localAttachmentClassForTiptapHref(href);
|
||||||
|
const attachmentRef = attachmentRefForTiptapHref(href, context);
|
||||||
const attrs = attachmentClass
|
const attrs = attachmentClass
|
||||||
? {
|
? withAttachmentProjectionAttrs({
|
||||||
...mark.attrs,
|
...mark.attrs,
|
||||||
href,
|
href,
|
||||||
class: mergeClassNames(mark.attrs.class, attachmentClass),
|
class: mergeClassNames(mark.attrs.class, attachmentClass),
|
||||||
target: mark.attrs.target || '_blank',
|
target: mark.attrs.target || '_blank',
|
||||||
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
|
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
|
||||||
}
|
}, attachmentRef)
|
||||||
: { ...mark.attrs, href };
|
: { ...mark.attrs, href };
|
||||||
return { ...mark, attrs };
|
return { ...mark, attrs };
|
||||||
});
|
});
|
||||||
@@ -405,12 +455,13 @@ export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||||
|
const projectionContext = { ...(context || {}), body };
|
||||||
if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') {
|
if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') {
|
||||||
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context);
|
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), projectionContext);
|
||||||
}
|
}
|
||||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||||
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context);
|
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext);
|
||||||
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context);
|
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), projectionContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const inlineTextNodes = (node) => {
|
export const inlineTextNodes = (node) => {
|
||||||
@@ -524,7 +575,7 @@ export const tiptapNodeToEditorBlock = (node, index) => {
|
|||||||
if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] };
|
if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] };
|
||||||
if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||||
if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] };
|
if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] };
|
||||||
if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] };
|
if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.mnoteMarkdownSrc || node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] };
|
||||||
if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] };
|
if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] };
|
||||||
if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ function uploadedAssetUrl(asset) {
|
|||||||
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadedAssetMarkdownHref(asset) {
|
||||||
|
var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim();
|
||||||
|
if (href) {
|
||||||
|
var normalizedHref = href.replace(/\\/g, '/');
|
||||||
|
if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return '';
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/');
|
||||||
|
if (!relativePath) return '';
|
||||||
|
if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return '';
|
||||||
|
if (relativePath.indexOf('./') === 0) return relativePath;
|
||||||
|
return './' + relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchUploadedEditorChange(editorRoot, editor, deps) {
|
function dispatchUploadedEditorChange(editorRoot, editor, deps) {
|
||||||
deps = deps || {};
|
deps = deps || {};
|
||||||
if (!(editorRoot instanceof HTMLElement) || !editor || typeof editor.getJSON !== 'function') return;
|
if (!(editorRoot instanceof HTMLElement) || !editor || typeof editor.getJSON !== 'function') return;
|
||||||
@@ -431,37 +445,31 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
var title = typeof deps.uploadedAssetTitle === 'function' ? deps.uploadedAssetTitle(asset) : uploadedAssetTitle(asset);
|
var title = typeof deps.uploadedAssetTitle === 'function' ? deps.uploadedAssetTitle(asset) : uploadedAssetTitle(asset);
|
||||||
|
var markdownHref = typeof deps.uploadedAssetMarkdownHref === 'function' ? deps.uploadedAssetMarkdownHref(asset) : uploadedAssetMarkdownHref(asset);
|
||||||
var localOpenUrl = typeof deps.localAssetOpenUrl === 'function' ? deps.localAssetOpenUrl(asset, false) : localAssetOpenUrl(asset, false);
|
var localOpenUrl = typeof deps.localAssetOpenUrl === 'function' ? deps.localAssetOpenUrl(asset, false) : localAssetOpenUrl(asset, false);
|
||||||
var fallbackUrl = typeof deps.uploadedAssetUrl === 'function' ? deps.uploadedAssetUrl(asset) : uploadedAssetUrl(asset);
|
var fallbackUrl = typeof deps.uploadedAssetUrl === 'function' ? deps.uploadedAssetUrl(asset) : uploadedAssetUrl(asset);
|
||||||
var url = localOpenUrl || fallbackUrl;
|
var url = markdownHref || localOpenUrl || fallbackUrl;
|
||||||
var type = typeof deps.uploadedAssetType === 'function' ? deps.uploadedAssetType(asset) : uploadedAssetType(asset);
|
var type = typeof deps.uploadedAssetType === 'function' ? deps.uploadedAssetType(asset) : uploadedAssetType(asset);
|
||||||
var assetId = String(asset && asset.id || '').trim();
|
var assetId = String(asset && asset.id || '').trim();
|
||||||
var sizeLabel = typeof deps.uploadedFileSize === 'function' ? deps.uploadedFileSize(asset) : uploadedFileSize(asset);
|
var sizeLabel = typeof deps.uploadedFileSize === 'function' ? deps.uploadedFileSize(asset) : uploadedFileSize(asset);
|
||||||
try {
|
try {
|
||||||
if (type === 'image' && url) {
|
if (type === 'image' && url) {
|
||||||
return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true;
|
var imageInserted = editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true;
|
||||||
|
if (imageInserted) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true');
|
||||||
|
document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || '');
|
||||||
|
document.documentElement.removeAttribute('data-mnote-last-upload-insert-error');
|
||||||
|
dispatchUploadedEditorChange(editorRoot, editor, deps);
|
||||||
|
await persistUploadedEditorChange(editor, asset, deps);
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false');
|
||||||
|
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_image_failed');
|
||||||
|
}
|
||||||
|
return imageInserted;
|
||||||
}
|
}
|
||||||
var isLocalAsset = typeof deps.isLocalUploadedAsset === 'function' ? deps.isLocalUploadedAsset(asset) : isLocalUploadedAsset(asset);
|
var isLocalAsset = typeof deps.isLocalUploadedAsset === 'function' ? deps.isLocalUploadedAsset(asset) : isLocalUploadedAsset(asset);
|
||||||
var userId = '';
|
var href = markdownHref || url;
|
||||||
var buildOnlyOfficeAssetOpenUrl = typeof deps.buildOnlyOfficeAssetOpenUrl === 'function' ? deps.buildOnlyOfficeAssetOpenUrl : function() { return ''; };
|
|
||||||
var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
|
||||||
if (onlyOfficeUrl && assetId) {
|
|
||||||
userId = typeof deps.fetchCurrentOnlyOfficeUserId === 'function' ? await deps.fetchCurrentOnlyOfficeUserId() : '';
|
|
||||||
onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
|
||||||
}
|
|
||||||
var href = onlyOfficeUrl || url;
|
|
||||||
if (href) {
|
if (href) {
|
||||||
var storedHref = onlyOfficeUrl
|
|
||||||
? (isLocalAsset ? onlyOfficeUrl : deps.buildOnlyOfficeOpenPath({
|
|
||||||
fileUrl: '',
|
|
||||||
fileName: title,
|
|
||||||
fileType: deps.inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx',
|
|
||||||
assetId: assetId,
|
|
||||||
documentId: String(asset && (asset.document_id || asset.documentId) || deps.currentDocumentId() || '').trim(),
|
|
||||||
userId: userId || '',
|
|
||||||
mode: 'view'
|
|
||||||
}))
|
|
||||||
: href;
|
|
||||||
var inserted = editor.chain().focus().insertContent([
|
var inserted = editor.chain().focus().insertContent([
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
@@ -471,7 +479,7 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
|
|||||||
marks: [{
|
marks: [{
|
||||||
type: 'link',
|
type: 'link',
|
||||||
attrs: {
|
attrs: {
|
||||||
href: storedHref,
|
href: href,
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
rel: 'noopener noreferrer nofollow',
|
rel: 'noopener noreferrer nofollow',
|
||||||
class: typeof deps.uploadedAttachmentClass === 'function' ? deps.uploadedAttachmentClass(asset) : uploadedAttachmentClass(asset)
|
class: typeof deps.uploadedAttachmentClass === 'function' ? deps.uploadedAttachmentClass(asset) : uploadedAttachmentClass(asset)
|
||||||
@@ -510,7 +518,6 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
|
|||||||
}) || null;
|
}) || null;
|
||||||
}
|
}
|
||||||
if (link instanceof HTMLElement) {
|
if (link instanceof HTMLElement) {
|
||||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
|
||||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||||
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
||||||
} else if (Number(attempt) < 20) {
|
} else if (Number(attempt) < 20) {
|
||||||
@@ -755,6 +762,7 @@ function uploadedFileSize(asset) {
|
|||||||
window.__mnoteLocalUploadRuntime = {
|
window.__mnoteLocalUploadRuntime = {
|
||||||
uploadedAssetTitle: uploadedAssetTitle,
|
uploadedAssetTitle: uploadedAssetTitle,
|
||||||
uploadedAssetUrl: uploadedAssetUrl,
|
uploadedAssetUrl: uploadedAssetUrl,
|
||||||
|
uploadedAssetMarkdownHref: uploadedAssetMarkdownHref,
|
||||||
resolveEditorUploadContext: resolveEditorUploadContext,
|
resolveEditorUploadContext: resolveEditorUploadContext,
|
||||||
editorRootFromUploadOptions: editorRootFromUploadOptions,
|
editorRootFromUploadOptions: editorRootFromUploadOptions,
|
||||||
openEditorUploadFilePicker: openEditorUploadFilePicker,
|
openEditorUploadFilePicker: openEditorUploadFilePicker,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
currentRootUri,
|
currentRootUri,
|
||||||
currentWorkspaceSourcePayload,
|
currentWorkspaceSourcePayload,
|
||||||
fileTreeIconKindForFileName,
|
fileTreeIconKindForFileName,
|
||||||
healLegacyOfficeAttachmentParagraphs,
|
|
||||||
hydrateEditorAttachmentMeta: injectedHydrateEditorAttachmentMeta,
|
hydrateEditorAttachmentMeta: injectedHydrateEditorAttachmentMeta,
|
||||||
inferCodeAttachmentLanguage,
|
inferCodeAttachmentLanguage,
|
||||||
inferOnlyOfficeFileType,
|
inferOnlyOfficeFileType,
|
||||||
@@ -38,7 +37,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
var attachmentActionsHideTimer = 0;
|
var attachmentActionsHideTimer = 0;
|
||||||
var editorAttachmentMissingByKey = new Map();
|
var editorAttachmentMissingByKey = new Map();
|
||||||
var editorAttachmentRefreshSeqByKey = new Map();
|
var editorAttachmentRefreshSeqByKey = new Map();
|
||||||
var editorAttachmentLinkSelector = '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]';
|
var editorAttachmentLinkSelector = '.editor-surface .ProseMirror a.mnote-uploaded-attachment-row';
|
||||||
|
var editorAttachmentEnhanceSelector = '.editor-surface .ProseMirror a[href], ' + editorAttachmentLinkSelector;
|
||||||
|
|
||||||
function attachmentQueryParams(href) {
|
function attachmentQueryParams(href) {
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +58,51 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeLocalEncodedPath(value) {
|
||||||
|
var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1');
|
||||||
|
if (!path) return '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(path);
|
||||||
|
} catch (_) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMarkdownPathFromDocumentId(documentId) {
|
||||||
|
var raw = String(documentId || '').trim();
|
||||||
|
if (raw.indexOf('local-md:') !== 0) return '';
|
||||||
|
return decodeLocalEncodedPath(raw.slice('local-md:'.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocalRelativePath(path) {
|
||||||
|
var parts = String(path || '').replace(/\\/g, '/').split('/');
|
||||||
|
var normalized = [];
|
||||||
|
for (var i = 0; i < parts.length; i += 1) {
|
||||||
|
var part = parts[i];
|
||||||
|
if (!part || part === '.') continue;
|
||||||
|
if (part === '..') {
|
||||||
|
if (normalized.length) normalized.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push(part);
|
||||||
|
}
|
||||||
|
return normalized.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMarkdownAttachmentPathFromHref(href, documentId) {
|
||||||
|
var raw = String(href || '').trim();
|
||||||
|
if (!raw || raw.indexOf('#') === 0 || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return '';
|
||||||
|
var documentPath = localMarkdownPathFromDocumentId(documentId);
|
||||||
|
if (!documentPath) return '';
|
||||||
|
var slashIndex = documentPath.lastIndexOf('/');
|
||||||
|
var documentDir = slashIndex >= 0 ? documentPath.slice(0, slashIndex) : '';
|
||||||
|
var decoded = raw;
|
||||||
|
try { decoded = decodeURIComponent(raw); } catch (_) {}
|
||||||
|
if (decoded.indexOf('../') === 0 || decoded.indexOf('/../') >= 0) return '';
|
||||||
|
var joined = documentDir ? documentDir + '/' + decoded : decoded;
|
||||||
|
return normalizeLocalRelativePath(joined);
|
||||||
|
}
|
||||||
|
|
||||||
function localFileOpenRootUriFromHref(href) {
|
function localFileOpenRootUriFromHref(href) {
|
||||||
try {
|
try {
|
||||||
var url = new URL(String(href || ''), window.location.origin);
|
var url = new URL(String(href || ''), window.location.origin);
|
||||||
@@ -84,6 +129,77 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLocalFileOpenUrlForRoot(relativePath, rootUri, download) {
|
||||||
|
var effectiveRootUri = String(rootUri || currentRootUri() || '').trim();
|
||||||
|
if (!effectiveRootUri || !relativePath) return '';
|
||||||
|
var url = new URL('/api/local-folder/files/open', window.location.origin);
|
||||||
|
url.searchParams.set('rootUri', effectiveRootUri);
|
||||||
|
url.searchParams.set('path', relativePath);
|
||||||
|
if (download) url.searchParams.set('download', 'true');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileUriToPath(uri) {
|
||||||
|
var value = String(uri || '').trim();
|
||||||
|
if (value.indexOf('file://') !== 0) return '';
|
||||||
|
try {
|
||||||
|
var url = new URL(value);
|
||||||
|
return decodeURIComponent(url.pathname || '');
|
||||||
|
} catch (_) {
|
||||||
|
var raw = value.slice('file://'.length);
|
||||||
|
if (raw.indexOf('localhost/') === 0) raw = raw.slice('localhost'.length);
|
||||||
|
if (raw.charAt(0) !== '/') raw = '/' + raw;
|
||||||
|
try { raw = decodeURIComponent(raw); } catch (_) {}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileUriFromPath(path) {
|
||||||
|
var value = String(path || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
return 'file://' + value.split('/').map(function(part, index) {
|
||||||
|
return index === 0 ? '' : encodeURIComponent(part);
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function localAttachmentOpenParts(rawHref, attachmentRef, paneContext) {
|
||||||
|
if (attachmentRef && typeof attachmentRef === 'object') {
|
||||||
|
var refRelativePath = String(attachmentRef.relativePath || '').trim();
|
||||||
|
if (refRelativePath) {
|
||||||
|
return {
|
||||||
|
rootUri: String(attachmentRef.ownerRootUri || currentRootUri() || '').trim(),
|
||||||
|
relativePath: refRelativePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var absolutePath = String(attachmentRef.resolvedAbsolutePath || '').trim()
|
||||||
|
|| fileUriToPath(attachmentRef.resolvedUri);
|
||||||
|
if (absolutePath) {
|
||||||
|
var slash = absolutePath.lastIndexOf('/');
|
||||||
|
if (slash > 0) {
|
||||||
|
return {
|
||||||
|
rootUri: fileUriFromPath(absolutePath.slice(0, slash)),
|
||||||
|
relativePath: absolutePath.slice(slash + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var apiPath = localFileOpenPathFromHref(rawHref);
|
||||||
|
if (apiPath) {
|
||||||
|
return {
|
||||||
|
rootUri: localFileOpenRootUriFromHref(rawHref) || currentRootUri() || '',
|
||||||
|
relativePath: apiPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var markdownPath = localMarkdownAttachmentPathFromHref(rawHref, paneContext && paneContext.documentId);
|
||||||
|
if (markdownPath) {
|
||||||
|
return {
|
||||||
|
rootUri: currentRootUri() || '',
|
||||||
|
relativePath: markdownPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPdfPreviewOpenUrl(fileUrl, fileName) {
|
function buildPdfPreviewOpenUrl(fileUrl, fileName) {
|
||||||
if (typeof injectedBuildPdfPreviewOpenUrl === 'function') {
|
if (typeof injectedBuildPdfPreviewOpenUrl === 'function') {
|
||||||
return injectedBuildPdfPreviewOpenUrl(fileUrl, fileName);
|
return injectedBuildPdfPreviewOpenUrl(fileUrl, fileName);
|
||||||
@@ -112,24 +228,43 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setEditorAttachmentUnauthorizedState(link, unauthorized) {
|
||||||
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
|
var value = Boolean(unauthorized);
|
||||||
|
if (value) {
|
||||||
|
if (link.getAttribute('data-mnote-attachment-unauthorized') === 'true' && link.classList.contains('mnote-uploaded-attachment-unauthorized')) return;
|
||||||
|
setAttributeIfChanged(link, 'data-mnote-attachment-unauthorized', 'true');
|
||||||
|
link.classList.add('mnote-uploaded-attachment-unauthorized');
|
||||||
|
setAttributeIfChanged(link, 'aria-label', (link.textContent || '附件') + '(无权访问)');
|
||||||
|
} else {
|
||||||
|
if (link.getAttribute('data-mnote-attachment-unauthorized') !== 'true') return;
|
||||||
|
link.removeAttribute('data-mnote-attachment-unauthorized');
|
||||||
|
link.classList.remove('mnote-uploaded-attachment-unauthorized');
|
||||||
|
if (link.getAttribute('data-mnote-attachment-missing') !== 'true') link.removeAttribute('aria-label');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setAttributeIfChanged(element, name, value) {
|
function setAttributeIfChanged(element, name, value) {
|
||||||
var nextValue = String(value || '');
|
var nextValue = String(value || '');
|
||||||
if (element.getAttribute(name) === nextValue) return;
|
if (element.getAttribute(name) === nextValue) return;
|
||||||
element.setAttribute(name, nextValue);
|
element.setAttribute(name, nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEditorAttachmentMissingStateByHref(href, missing) {
|
function setEditorAttachmentMissingStateByHref(href, missing, targetKeyOverride) {
|
||||||
var targetKey = localFileOpenKeyFromHref(href);
|
var targetKey = String(targetKeyOverride || '').trim() || localFileOpenKeyFromHref(href);
|
||||||
if (!targetKey) return;
|
if (!targetKey) return;
|
||||||
if (missing) {
|
if (missing) {
|
||||||
editorAttachmentMissingByKey.set(targetKey, true);
|
editorAttachmentMissingByKey.set(targetKey, true);
|
||||||
} else {
|
} else {
|
||||||
editorAttachmentMissingByKey.delete(targetKey);
|
editorAttachmentMissingByKey.delete(targetKey);
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) {
|
document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) {
|
||||||
if (!(candidate instanceof HTMLAnchorElement)) return;
|
if (!(candidate instanceof HTMLAnchorElement)) return;
|
||||||
var candidateHref = candidate.getAttribute('href') || candidate.href || '';
|
var candidateHref = candidate.getAttribute('href') || candidate.href || '';
|
||||||
if (localFileOpenKeyFromHref(candidateHref) !== targetKey) return;
|
var candidateRef = attachmentRefForHref(candidateHref, editorAttachmentPaneContext(candidate), candidate.textContent || '');
|
||||||
|
var candidateParts = localAttachmentOpenParts(candidateHref, candidateRef, editorAttachmentPaneContext(candidate));
|
||||||
|
var candidateKey = candidateParts ? String(candidateParts.rootUri || '').trim() + '\n' + String(candidateParts.relativePath || '').trim() : localFileOpenKeyFromHref(candidateHref);
|
||||||
|
if (candidateKey !== targetKey) return;
|
||||||
setEditorAttachmentMissingState(candidate, missing);
|
setEditorAttachmentMissingState(candidate, missing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -137,35 +272,41 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
async function refreshLocalAttachmentExistence(link) {
|
async function refreshLocalAttachmentExistence(link) {
|
||||||
if (!(link instanceof HTMLAnchorElement)) return;
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
var href = link.getAttribute('href') || link.href || '';
|
var href = link.getAttribute('href') || link.href || '';
|
||||||
var attachmentKey = localFileOpenKeyFromHref(href);
|
var paneContext = editorAttachmentPaneContext(link);
|
||||||
|
var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : '');
|
||||||
|
var openParts = localAttachmentOpenParts(href, attachmentRef, paneContext);
|
||||||
|
var attachmentKey = openParts ? String(openParts.rootUri || '').trim() + '\n' + String(openParts.relativePath || '').trim() : localFileOpenKeyFromHref(href);
|
||||||
if (!attachmentKey) return;
|
if (!attachmentKey) return;
|
||||||
var refreshSeq = (editorAttachmentRefreshSeqByKey.get(attachmentKey) || 0) + 1;
|
var refreshSeq = (editorAttachmentRefreshSeqByKey.get(attachmentKey) || 0) + 1;
|
||||||
editorAttachmentRefreshSeqByKey.set(attachmentKey, refreshSeq);
|
editorAttachmentRefreshSeqByKey.set(attachmentKey, refreshSeq);
|
||||||
var localFilePath = localFileOpenPathFromHref(href);
|
var localFilePath = openParts ? openParts.relativePath : localFileOpenPathFromHref(href);
|
||||||
if (!localFilePath) return;
|
if (!localFilePath) return;
|
||||||
var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href));
|
var statusUrl = buildLocalFileStatusUrl(localFilePath, openParts ? openParts.rootUri : localFileOpenRootUriFromHref(href));
|
||||||
if (!statusUrl) return;
|
if (!statusUrl) return;
|
||||||
try {
|
try {
|
||||||
var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
|
var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
|
||||||
var payload = await response.json().catch(function() { return null; });
|
var payload = await response.json().catch(function() { return null; });
|
||||||
if (editorAttachmentRefreshSeqByKey.get(attachmentKey) !== refreshSeq) return;
|
if (editorAttachmentRefreshSeqByKey.get(attachmentKey) !== refreshSeq) return;
|
||||||
if (!response.ok || !payload || payload.ok !== true || !payload.result) {
|
if (!response.ok || !payload || payload.ok !== true || !payload.result) {
|
||||||
setEditorAttachmentStatusErrorByHref(href, String(response.status || 'stat_failed'));
|
setEditorAttachmentStatusErrorByHref(href, String(response.status || 'stat_failed'), attachmentKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
|
var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
|
||||||
setEditorAttachmentStatusErrorByHref(href, '');
|
setEditorAttachmentStatusErrorByHref(href, '', attachmentKey);
|
||||||
setEditorAttachmentMissingStateByHref(href, !exists);
|
setEditorAttachmentMissingStateByHref(href, !exists, attachmentKey);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEditorAttachmentStatusErrorByHref(href, status) {
|
function setEditorAttachmentStatusErrorByHref(href, status, targetKeyOverride) {
|
||||||
var targetKey = localFileOpenKeyFromHref(href);
|
var targetKey = String(targetKeyOverride || '').trim() || localFileOpenKeyFromHref(href);
|
||||||
if (!targetKey) return;
|
if (!targetKey) return;
|
||||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) {
|
document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) {
|
||||||
if (!(candidate instanceof HTMLAnchorElement)) return;
|
if (!(candidate instanceof HTMLAnchorElement)) return;
|
||||||
var candidateHref = candidate.getAttribute('href') || candidate.href || '';
|
var candidateHref = candidate.getAttribute('href') || candidate.href || '';
|
||||||
if (localFileOpenKeyFromHref(candidateHref) !== targetKey) return;
|
var candidateRef = attachmentRefForHref(candidateHref, editorAttachmentPaneContext(candidate), candidate.textContent || '');
|
||||||
|
var candidateParts = localAttachmentOpenParts(candidateHref, candidateRef, editorAttachmentPaneContext(candidate));
|
||||||
|
var candidateKey = candidateParts ? String(candidateParts.rootUri || '').trim() + '\n' + String(candidateParts.relativePath || '').trim() : localFileOpenKeyFromHref(candidateHref);
|
||||||
|
if (candidateKey !== targetKey) return;
|
||||||
if (status) {
|
if (status) {
|
||||||
setAttributeIfChanged(candidate, 'data-mnote-attachment-status-error', status);
|
setAttributeIfChanged(candidate, 'data-mnote-attachment-status-error', status);
|
||||||
} else {
|
} else {
|
||||||
@@ -175,15 +316,18 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshEditorLocalAttachmentExistence() {
|
function refreshEditorLocalAttachmentExistence() {
|
||||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
|
document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
|
||||||
if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
|
if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
|
window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
|
||||||
|
|
||||||
|
var attachmentExistenceRefreshFrame = 0;
|
||||||
function scheduleEditorLocalAttachmentExistenceRefresh() {
|
function scheduleEditorLocalAttachmentExistenceRefresh() {
|
||||||
[120, 500, 1200].forEach(function(delayMs) {
|
if (attachmentExistenceRefreshFrame) return;
|
||||||
window.setTimeout(refreshEditorLocalAttachmentExistence, delayMs);
|
attachmentExistenceRefreshFrame = window.requestAnimationFrame(function() {
|
||||||
|
attachmentExistenceRefreshFrame = 0;
|
||||||
|
refreshEditorLocalAttachmentExistence();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,19 +398,57 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentPageAggregate() {
|
||||||
|
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
|
||||||
|
if (!(script instanceof HTMLScriptElement)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(script.textContent || 'null');
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentRefForHref(rawHref, paneContext, label) {
|
||||||
|
var aggregate = currentPageAggregate();
|
||||||
|
var refs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : [];
|
||||||
|
if (!refs.length) return null;
|
||||||
|
var href = String(rawHref || '').trim();
|
||||||
|
var textLabel = String(label || '').trim();
|
||||||
|
var absoluteHref = '';
|
||||||
|
try {
|
||||||
|
absoluteHref = new URL(href, window.location.href).href;
|
||||||
|
} catch (_) {}
|
||||||
|
return refs.find(function(ref) {
|
||||||
|
if (!ref || typeof ref !== 'object') return false;
|
||||||
|
return String(ref.rawHref || '') === href
|
||||||
|
|| String(ref.normalizedHref || '') === href
|
||||||
|
|| String(ref.resolvedUri || '') === href
|
||||||
|
|| (absoluteHref && String(ref.resolvedUri || '') === absoluteHref)
|
||||||
|
|| (!href && textLabel && String(ref.label || '').trim() === textLabel);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function detailFromEditorAttachmentLink(link) {
|
function detailFromEditorAttachmentLink(link) {
|
||||||
var rawHref = link instanceof HTMLAnchorElement ? link.href : '';
|
var rawHref = link instanceof HTMLAnchorElement ? (link.getAttribute('href') || '') : '';
|
||||||
var params = attachmentQueryParams(rawHref);
|
var params = attachmentQueryParams(rawHref);
|
||||||
var paneContext = editorAttachmentPaneContext(link);
|
var paneContext = editorAttachmentPaneContext(link);
|
||||||
var localFilePath = localFileOpenPathFromHref(rawHref);
|
var attachmentRef = attachmentRefForHref(rawHref, paneContext, link ? link.textContent : '');
|
||||||
var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件';
|
if (!rawHref && attachmentRef) rawHref = String(attachmentRef.rawHref || attachmentRef.normalizedHref || attachmentRef.resolvedUri || '').trim();
|
||||||
var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, '');
|
var openParts = localAttachmentOpenParts(rawHref, attachmentRef, paneContext);
|
||||||
|
var localFilePath = openParts && openParts.relativePath ? openParts.relativePath : (
|
||||||
|
String(attachmentRef?.relativePath || '').trim()
|
||||||
|
|| localFileOpenPathFromHref(rawHref)
|
||||||
|
|| localMarkdownAttachmentPathFromHref(rawHref, paneContext.documentId)
|
||||||
|
);
|
||||||
|
var localRootUri = openParts && openParts.rootUri ? openParts.rootUri : currentRootUri();
|
||||||
|
var fileName = params.get('fileName') || String(attachmentRef?.label || '').trim() || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件';
|
||||||
|
var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, '') || String(attachmentRef?.ext || '').trim();
|
||||||
var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || (localFilePath ? 'local-file:' + localFilePath : '');
|
var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || (localFilePath ? 'local-file:' + localFilePath : '');
|
||||||
var fileUrl = params.get('fileUrl') || '';
|
var fileUrl = params.get('fileUrl') || (localFilePath ? buildLocalFileOpenUrlForRoot(localFilePath, localRootUri, false) : '') || String(attachmentRef?.resolvedUri || '').trim();
|
||||||
var documentId = params.get('documentId') || paneContext.documentId || '';
|
var documentId = params.get('documentId') || paneContext.documentId || '';
|
||||||
var href = rawHref;
|
var href = rawHref;
|
||||||
if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) {
|
if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) {
|
||||||
fileUrl = rawHref;
|
fileUrl = localFilePath ? buildLocalFileOpenUrlForRoot(localFilePath, localRootUri, false) : rawHref;
|
||||||
href = buildOnlyOfficeOpenUrl({
|
href = buildOnlyOfficeOpenUrl({
|
||||||
fileUrl: fileUrl,
|
fileUrl: fileUrl,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -289,33 +471,53 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
workspaceId: paneContext.workspaceId,
|
workspaceId: paneContext.workspaceId,
|
||||||
paneRole: paneContext.paneRole,
|
paneRole: paneContext.paneRole,
|
||||||
fileSize: (link ? link.getAttribute('data-file-size') : '') || ''
|
localRootUri: localRootUri,
|
||||||
|
localRelativePath: localFilePath,
|
||||||
|
fileSize: (link ? link.getAttribute('data-file-size') : '') || '',
|
||||||
|
attachmentRef: attachmentRef || null,
|
||||||
|
authorized: typeof attachmentRef?.authorized === 'boolean' ? attachmentRef.authorized : null,
|
||||||
|
exists: typeof attachmentRef?.exists === 'boolean' ? attachmentRef.exists : null,
|
||||||
|
openKind: String(attachmentRef?.openKind || '').trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function enhanceEditorAttachmentLink(link) {
|
function enhanceEditorAttachmentLink(link) {
|
||||||
if (!(link instanceof HTMLAnchorElement)) return;
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
var href = link.getAttribute('href') || '';
|
var href = link.getAttribute('href') || '';
|
||||||
var params = attachmentQueryParams(href);
|
var params = attachmentQueryParams(href);
|
||||||
var localFilePath = localFileOpenPathFromHref(href);
|
|
||||||
var paneContext = editorAttachmentPaneContext(link);
|
var paneContext = editorAttachmentPaneContext(link);
|
||||||
var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || link.textContent || '';
|
var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : '');
|
||||||
|
if (!href && attachmentRef) href = String(attachmentRef.rawHref || attachmentRef.normalizedHref || attachmentRef.resolvedUri || '').trim();
|
||||||
|
var openParts = localAttachmentOpenParts(href, attachmentRef, paneContext);
|
||||||
|
var localFilePath = openParts && openParts.relativePath ? openParts.relativePath : (localFileOpenPathFromHref(href) || localMarkdownAttachmentPathFromHref(href, paneContext.documentId));
|
||||||
|
var resolvedPathName = fileNameFromPath(localFilePath || fileUriToPath(attachmentRef && attachmentRef.resolvedUri));
|
||||||
|
var fileName = params.get('fileName') || resolvedPathName || link.textContent || '';
|
||||||
var className = link.getAttribute('class') || '';
|
var className = link.getAttribute('class') || '';
|
||||||
var shouldEnhance = isOnlyOfficeAttachmentHref(href)
|
var shouldEnhance = isOnlyOfficeAttachmentHref(href)
|
||||||
|| className.indexOf('mnote-uploaded-attachment-row') >= 0
|
|| className.indexOf('mnote-uploaded-attachment-row') >= 0
|
||||||
|| isOfficeFileName(fileName)
|
|| isOfficeFileName(fileName)
|
||||||
|| Boolean(localFilePath);
|
|| Boolean(localFilePath);
|
||||||
if (!shouldEnhance) return;
|
if (!shouldEnhance) return;
|
||||||
setAttributeIfChanged(link, 'data-mnote-attachment-link', 'true');
|
|
||||||
var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : '');
|
var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : '');
|
||||||
if (assetId) setAttributeIfChanged(link, 'data-asset-id', assetId);
|
if (assetId) setAttributeIfChanged(link, 'data-asset-id', assetId);
|
||||||
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
||||||
if (name) link.classList.add(name);
|
if (name) link.classList.add(name);
|
||||||
});
|
});
|
||||||
|
if (attachmentRef && typeof attachmentRef === 'object') {
|
||||||
|
if (typeof attachmentRef.exists === 'boolean') {
|
||||||
|
setEditorAttachmentMissingState(link, attachmentRef.exists === false);
|
||||||
|
}
|
||||||
|
if (typeof attachmentRef.authorized === 'boolean') {
|
||||||
|
setEditorAttachmentUnauthorizedState(link, attachmentRef.authorized === false);
|
||||||
|
}
|
||||||
|
}
|
||||||
setAttributeIfChanged(link, 'target', '_blank');
|
setAttributeIfChanged(link, 'target', '_blank');
|
||||||
setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow');
|
setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow');
|
||||||
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
|
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
|
||||||
setEditorAttachmentMissingState(link, editorAttachmentMissingByKey.has(localFileOpenKeyFromHref(href)));
|
var localAttachmentKey = openParts
|
||||||
|
? String(openParts.rootUri || '').trim() + '\n' + String(openParts.relativePath || '').trim()
|
||||||
|
: localFileOpenKeyFromHref(href);
|
||||||
|
var isProjectionMissing = typeof attachmentRef?.exists === 'boolean' && attachmentRef.exists === false;
|
||||||
|
setEditorAttachmentMissingState(link, isProjectionMissing || editorAttachmentMissingByKey.has(localAttachmentKey));
|
||||||
void refreshLocalAttachmentExistence(link);
|
void refreshLocalAttachmentExistence(link);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -334,26 +536,12 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enhanceEditorAttachmentLinks() {
|
function enhanceEditorAttachmentLinks() {
|
||||||
document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink);
|
document.querySelectorAll(editorAttachmentEnhanceSelector).forEach(enhanceEditorAttachmentLink);
|
||||||
void healLegacyOfficeAttachmentParagraphs();
|
|
||||||
}
|
}
|
||||||
window.__mnoteEnhanceEditorAttachmentLinks = function() {
|
window.__mnoteEnhanceEditorAttachmentLinks = function() {
|
||||||
observeEditorAttachmentRoots();
|
observeEditorAttachmentRoots();
|
||||||
enhanceEditorAttachmentLinks();
|
enhanceEditorAttachmentLinks();
|
||||||
};
|
};
|
||||||
var attachmentInitialEnhanceAttempts = 0;
|
|
||||||
var attachmentInitialEnhanceTimer = window.setInterval(function() {
|
|
||||||
attachmentInitialEnhanceAttempts += 1;
|
|
||||||
enhanceEditorAttachmentLinks();
|
|
||||||
if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer);
|
|
||||||
}, 500);
|
|
||||||
var attachmentExistenceRefreshTimer = window.setInterval(function() {
|
|
||||||
refreshEditorLocalAttachmentExistence();
|
|
||||||
}, 2500);
|
|
||||||
window.addEventListener('beforeunload', function() {
|
|
||||||
if (attachmentExistenceRefreshTimer) window.clearInterval(attachmentExistenceRefreshTimer);
|
|
||||||
attachmentExistenceRefreshTimer = 0;
|
|
||||||
});
|
|
||||||
var lastEditorAttachmentMouseOpen = { at: 0, href: '' };
|
var lastEditorAttachmentMouseOpen = { at: 0, href: '' };
|
||||||
|
|
||||||
function ensureAttachmentActions() {
|
function ensureAttachmentActions() {
|
||||||
@@ -394,25 +582,53 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openEditorAttachmentDetail(detail) {
|
async function openEditorAttachmentDetail(detail) {
|
||||||
if (!detail || !detail.href) return;
|
if (!detail) return;
|
||||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
if (detail.authorized === false) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-attachment-open-blocked', 'unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (detail.exists === false) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-attachment-open-blocked', 'missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!detail.href) return;
|
||||||
|
document.documentElement.removeAttribute('data-mnote-attachment-open-blocked');
|
||||||
|
var localFilePath = localFilePathFromAssetId(detail.assetId) || String(detail.localRelativePath || '').trim();
|
||||||
if (localFilePath) {
|
if (localFilePath) {
|
||||||
if (await openLocalOfficeFileInActiveTab(detail, 'view')) return;
|
var localRootUri = String(detail.localRootUri || currentRootUri() || '').trim();
|
||||||
|
if (!detail.localRootUri && await openLocalOfficeFileInActiveTab(detail, 'view')) return;
|
||||||
var localFileName = detail.fileName || localFilePath.split('/').pop() || localFilePath;
|
var localFileName = detail.fileName || localFilePath.split('/').pop() || localFilePath;
|
||||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
var localFileUrl = buildLocalFileOpenUrlForRoot(localFilePath, localRootUri, false);
|
||||||
var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl;
|
var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl;
|
||||||
|
var localOfficeType = inferOnlyOfficeFileType(localFileName, '');
|
||||||
|
var localOfficeUrl = localOfficeType
|
||||||
|
? buildOnlyOfficeOpenUrl({
|
||||||
|
fileUrl: localFileUrl,
|
||||||
|
fileName: localFileName,
|
||||||
|
fileType: localOfficeType,
|
||||||
|
assetId: detail.assetId || ('local-file:' + localFilePath),
|
||||||
|
documentId: detail.documentId || currentDocumentId() || '',
|
||||||
|
userId: '',
|
||||||
|
mode: 'view',
|
||||||
|
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||||
|
sourceKind: 'local_folder',
|
||||||
|
rootUri: localRootUri
|
||||||
|
})
|
||||||
|
: '';
|
||||||
// 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口
|
// 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口
|
||||||
void openLocalResourceInActiveTab({
|
void openLocalResourceInActiveTab({
|
||||||
path: localFilePath,
|
path: localFilePath,
|
||||||
title: localFileName,
|
title: localFileName,
|
||||||
kind: fileTreeIconKindForFileName(localFileName),
|
kind: localOfficeType ? 'office' : fileTreeIconKindForFileName(localFileName),
|
||||||
assetId: detail.assetId,
|
assetId: detail.assetId,
|
||||||
documentId: detail.documentId || currentDocumentId() || '',
|
documentId: detail.documentId || currentDocumentId() || '',
|
||||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||||
href: localOpenUrl,
|
href: localOpenUrl,
|
||||||
|
officeUrl: localOfficeUrl,
|
||||||
|
rootUri: localRootUri,
|
||||||
paneRole: detail.paneRole || 'primary'
|
paneRole: detail.paneRole || 'primary'
|
||||||
}).then(function(opened) {
|
}).then(function(opened) {
|
||||||
if (!opened) window.open(localOpenUrl || detail.href, '_blank', 'noopener,noreferrer');
|
if (!opened) window.open(localOfficeUrl || localOpenUrl || detail.href, '_blank', 'noopener,noreferrer');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -631,9 +847,9 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
|
|
||||||
async function openEditorAttachmentDownload(detail) {
|
async function openEditorAttachmentDownload(detail) {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
var localFilePath = localFilePathFromAssetId(detail.assetId) || String(detail.localRelativePath || '').trim();
|
||||||
if (localFilePath) {
|
if (localFilePath) {
|
||||||
var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true);
|
var localDownloadUrl = buildLocalFileOpenUrlForRoot(localFilePath, detail.localRootUri || currentRootUri(), true);
|
||||||
if (localDownloadUrl) {
|
if (localDownloadUrl) {
|
||||||
triggerBrowserDownload(localDownloadUrl);
|
triggerBrowserDownload(localDownloadUrl);
|
||||||
return;
|
return;
|
||||||
@@ -693,8 +909,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
function addedNodeMayContainEditorAttachmentLink(node) {
|
function addedNodeMayContainEditorAttachmentLink(node) {
|
||||||
return node instanceof HTMLElement && (
|
return node instanceof HTMLElement && (
|
||||||
node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div')
|
node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div')
|
||||||
|| node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href]')
|
|| node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function observeEditorAttachmentRoots() {
|
function observeEditorAttachmentRoots() {
|
||||||
@@ -706,10 +922,34 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
attachmentEditorObserver.observe(editor, { childList: true, subtree: true });
|
attachmentEditorObserver.observe(editor, { childList: true, subtree: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
var attachmentAggregateObserver = null;
|
||||||
|
var attachmentObservedAggregateScript = null;
|
||||||
|
function observePageAggregateScript() {
|
||||||
|
if (typeof MutationObserver !== 'function') return;
|
||||||
|
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
|
||||||
|
if (!(script instanceof HTMLScriptElement)) return;
|
||||||
|
if (script === attachmentObservedAggregateScript) return;
|
||||||
|
if (attachmentAggregateObserver) attachmentAggregateObserver.disconnect();
|
||||||
|
attachmentObservedAggregateScript = script;
|
||||||
|
attachmentAggregateObserver = new MutationObserver(function() {
|
||||||
|
scheduleEditorAttachmentEnhance();
|
||||||
|
scheduleEditorLocalAttachmentExistenceRefresh();
|
||||||
|
});
|
||||||
|
attachmentAggregateObserver.observe(script, {
|
||||||
|
attributes: true,
|
||||||
|
characterData: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
attachmentEditorObserver = new MutationObserver(function(records) {
|
attachmentEditorObserver = new MutationObserver(function(records) {
|
||||||
var shouldEnhance = Array.isArray(records) && records.some(function(record) {
|
var shouldEnhance = Array.isArray(records) && records.some(function(record) {
|
||||||
if (!record || record.type !== 'childList') return false;
|
if (!record || record.type !== 'childList') return false;
|
||||||
return Array.from(record.addedNodes || []).some(function(node) {
|
return Array.from(record.addedNodes || []).some(function(node) {
|
||||||
|
if (node instanceof HTMLElement && node.id === '__MNOTE_PAGE_AGGREGATE__') {
|
||||||
|
observePageAggregateScript();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return addedNodeMayContainEditorAttachmentLink(node);
|
return addedNodeMayContainEditorAttachmentLink(node);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -718,10 +958,21 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
});
|
});
|
||||||
attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true });
|
attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
observeEditorAttachmentRoots();
|
observeEditorAttachmentRoots();
|
||||||
|
observePageAggregateScript();
|
||||||
window.addEventListener('mnote:editor-attachment-links-changed', function() {
|
window.addEventListener('mnote:editor-attachment-links-changed', function() {
|
||||||
window.__mnoteEnhanceEditorAttachmentLinks();
|
window.__mnoteEnhanceEditorAttachmentLinks();
|
||||||
scheduleEditorAttachmentEnhance();
|
scheduleEditorAttachmentEnhance();
|
||||||
});
|
});
|
||||||
|
window.addEventListener('mnote:page-aggregate-synced', function() {
|
||||||
|
window.__mnoteEnhanceEditorAttachmentLinks();
|
||||||
|
scheduleEditorAttachmentEnhance();
|
||||||
|
scheduleEditorLocalAttachmentExistenceRefresh();
|
||||||
|
});
|
||||||
|
window.addEventListener('mnote:primary-document-activated', function() {
|
||||||
|
window.__mnoteEnhanceEditorAttachmentLinks();
|
||||||
|
scheduleEditorAttachmentEnhance();
|
||||||
|
scheduleEditorLocalAttachmentExistenceRefresh();
|
||||||
|
});
|
||||||
window.addEventListener('tree:delta', scheduleEditorLocalAttachmentExistenceRefresh);
|
window.addEventListener('tree:delta', scheduleEditorLocalAttachmentExistenceRefresh);
|
||||||
window.addEventListener('tree:resync', scheduleEditorLocalAttachmentExistenceRefresh);
|
window.addEventListener('tree:resync', scheduleEditorLocalAttachmentExistenceRefresh);
|
||||||
[
|
[
|
||||||
@@ -732,18 +983,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
|||||||
window.addEventListener(eventName, function() {
|
window.addEventListener(eventName, function() {
|
||||||
enhanceEditorAttachmentLinks();
|
enhanceEditorAttachmentLinks();
|
||||||
observeEditorAttachmentRoots();
|
observeEditorAttachmentRoots();
|
||||||
window.setTimeout(function() {
|
|
||||||
enhanceEditorAttachmentLinks();
|
|
||||||
observeEditorAttachmentRoots();
|
|
||||||
}, 120);
|
|
||||||
}, true);
|
}, true);
|
||||||
});
|
});
|
||||||
[120, 500, 1200, 2500].forEach(function(delayMs) {
|
|
||||||
window.setTimeout(function() {
|
|
||||||
enhanceEditorAttachmentLinks();
|
|
||||||
observeEditorAttachmentRoots();
|
|
||||||
}, delayMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
function interceptEditorAttachmentLink(event) {
|
function interceptEditorAttachmentLink(event) {
|
||||||
var editorAttachmentLink = closestAction(event.target, editorAttachmentLinkSelector);
|
var editorAttachmentLink = closestAction(event.target, editorAttachmentLinkSelector);
|
||||||
|
|||||||
@@ -1028,21 +1028,24 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
function selectSidebarFileTreeDocument(documentId, options) {
|
function selectSidebarFileTreeDocument(documentId, options) {
|
||||||
var id = String(documentId || '').trim();
|
var id = String(documentId || '').trim();
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]')
|
|
||||||
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]')
|
|
||||||
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]');
|
|
||||||
if (!(row instanceof HTMLElement)) {
|
|
||||||
var relativePath = options && options.relativePath
|
var relativePath = options && options.relativePath
|
||||||
? String(options.relativePath || '').trim()
|
? String(options.relativePath || '').trim()
|
||||||
: id.indexOf('local-md:') === 0
|
: id.indexOf('local-md:') === 0
|
||||||
? decodeLocalEncodedPath(id.slice('local-md:'.length))
|
? decodeLocalEncodedPath(id.slice('local-md:'.length))
|
||||||
: '';
|
: '';
|
||||||
var bundleParentPath = localMarkdownBundleParentPath(relativePath);
|
var targetRowId = options && options.rowId ? String(options.rowId || '').trim() : '';
|
||||||
var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null;
|
var row = targetRowId
|
||||||
if (bundleRow instanceof HTMLElement) {
|
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(targetRowId) + '"]')
|
||||||
return activateSidebarFileTreeRow(bundleRow, options);
|
: null;
|
||||||
|
if (!(row instanceof HTMLElement)) {
|
||||||
|
row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]')
|
||||||
|
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]')
|
||||||
|
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]');
|
||||||
}
|
}
|
||||||
if (relativePath && typeof revealFileTreeResource === 'function') {
|
if (row instanceof HTMLElement && relativePath && fileTreeRowLocalRelativePath(row) !== relativePath) {
|
||||||
|
row = null;
|
||||||
|
}
|
||||||
|
if (row instanceof HTMLElement && row.closest('.tree-children--collapsed') && relativePath && typeof revealFileTreeResource === 'function') {
|
||||||
void revealFileTreeResource({
|
void revealFileTreeResource({
|
||||||
rootUri: options && options.rootUri,
|
rootUri: options && options.rootUri,
|
||||||
relativePath: relativePath,
|
relativePath: relativePath,
|
||||||
@@ -1053,6 +1056,28 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (!(row instanceof HTMLElement)) {
|
||||||
|
if (relativePath && typeof revealFileTreeResource === 'function') {
|
||||||
|
void revealFileTreeResource({
|
||||||
|
rootUri: options && options.rootUri,
|
||||||
|
relativePath: relativePath,
|
||||||
|
rowId: options && options.rowId,
|
||||||
|
select: true,
|
||||||
|
focus: true,
|
||||||
|
scroll: options ? options.scrollIntoView !== false : true
|
||||||
|
}).then(function(revealed) {
|
||||||
|
if (revealed) return;
|
||||||
|
var fallbackParentPath = localMarkdownBundleParentPath(relativePath);
|
||||||
|
var fallbackBundleRow = fallbackParentPath ? visibleFileTreeRowByRelativePath(fallbackParentPath) : null;
|
||||||
|
if (fallbackBundleRow instanceof HTMLElement) activateSidebarFileTreeRow(fallbackBundleRow, options);
|
||||||
|
}).catch(function() {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var bundleParentPath = localMarkdownBundleParentPath(relativePath);
|
||||||
|
var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null;
|
||||||
|
if (bundleRow instanceof HTMLElement) {
|
||||||
|
return activateSidebarFileTreeRow(bundleRow, options);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return activateSidebarFileTreeRow(row, options);
|
return activateSidebarFileTreeRow(row, options);
|
||||||
@@ -1116,25 +1141,33 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-mnote-local-folder-restore-focused-row-id', rowId);
|
document.documentElement.setAttribute('data-mnote-local-folder-restore-focused-row-id', rowId);
|
||||||
document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'focused');
|
document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'focused');
|
||||||
|
clearPendingLocalFolderRestoreRowId();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePendingLocalFolderRestoreFocus() {
|
function schedulePendingLocalFolderRestoreFocus(options) {
|
||||||
if (!pendingLocalFolderRestoreRowId()) return false;
|
if (!pendingLocalFolderRestoreRowId()) return false;
|
||||||
if (window.__mnotePendingLocalFolderRestoreFocusTimer) return false;
|
var reason = options && options.reason ? String(options.reason) : '';
|
||||||
var attempts = 0;
|
var attempt = Math.max(0, Number(options && options.attempt || 0));
|
||||||
var focused = false;
|
var focused = applyPendingLocalFolderRestoreFocusOnce();
|
||||||
focused = applyPendingLocalFolderRestoreFocusOnce() || focused;
|
if (focused) return true;
|
||||||
window.__mnotePendingLocalFolderRestoreFocusTimer = window.setInterval(function() {
|
if (attempt >= 4) {
|
||||||
attempts += 1;
|
document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'timeout');
|
||||||
focused = applyPendingLocalFolderRestoreFocusOnce() || focused;
|
return false;
|
||||||
if (attempts >= 20) {
|
|
||||||
if (focused) clearPendingLocalFolderRestoreRowId();
|
|
||||||
else document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'timeout');
|
|
||||||
window.clearInterval(window.__mnotePendingLocalFolderRestoreFocusTimer);
|
|
||||||
window.__mnotePendingLocalFolderRestoreFocusTimer = 0;
|
|
||||||
}
|
}
|
||||||
}, 250);
|
if (window.__mnotePendingLocalFolderRestoreFocusFrame) {
|
||||||
|
window.cancelAnimationFrame(window.__mnotePendingLocalFolderRestoreFocusFrame);
|
||||||
|
window.__mnotePendingLocalFolderRestoreFocusFrame = 0;
|
||||||
|
}
|
||||||
|
window.__mnotePendingLocalFolderRestoreFocusFrame = window.requestAnimationFrame(function() {
|
||||||
|
window.__mnotePendingLocalFolderRestoreFocusFrame = 0;
|
||||||
|
window.setTimeout(function() {
|
||||||
|
schedulePendingLocalFolderRestoreFocus({
|
||||||
|
reason: reason || 'retry',
|
||||||
|
attempt: attempt + 1,
|
||||||
|
});
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
return focused;
|
return focused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
|||||||
var draggingPageNodeId = '';
|
var draggingPageNodeId = '';
|
||||||
var activePageDropRow = null;
|
var activePageDropRow = null;
|
||||||
|
|
||||||
|
function localCreateSelectionOptions(result) {
|
||||||
|
var selectTarget = result && result.selectTarget && typeof result.selectTarget === 'object'
|
||||||
|
? result.selectTarget
|
||||||
|
: result && result.revealTarget && typeof result.revealTarget === 'object'
|
||||||
|
? result.revealTarget
|
||||||
|
: null;
|
||||||
|
if (!selectTarget) return { scrollIntoView: true };
|
||||||
|
return {
|
||||||
|
scrollIntoView: true,
|
||||||
|
rootUri: result.rootUri || '',
|
||||||
|
relativePath: selectTarget.relativePath || selectTarget.relative_path || '',
|
||||||
|
rowId: selectTarget.rowId || selectTarget.row_id || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function navigateToDocument(nodeId, workspaceId, options) {
|
function navigateToDocument(nodeId, workspaceId, options) {
|
||||||
if (!nodeId) return;
|
if (!nodeId) return;
|
||||||
var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode());
|
var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode());
|
||||||
@@ -111,7 +126,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
|||||||
if (typeof refreshLocalFolderAfterCommand === 'function') {
|
if (typeof refreshLocalFolderAfterCommand === 'function') {
|
||||||
await refreshLocalFolderAfterCommand('create', result, { parentId: effectiveParentId || null });
|
await refreshLocalFolderAfterCommand('create', result, { parentId: effectiveParentId || null });
|
||||||
}
|
}
|
||||||
selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true });
|
selectSidebarFileTreeDocument(nextDocumentId, localCreateSelectionOptions(result));
|
||||||
document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId);
|
document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId);
|
||||||
}
|
}
|
||||||
navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() });
|
navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() });
|
||||||
|
|||||||
@@ -423,17 +423,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
void refreshLocalFolderSidebarSnapshot();
|
void refreshLocalFolderSidebarSnapshot();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
var changedPaths = Array.isArray(batch && batch.changedPaths)
|
||||||
|
? batch.changedPaths
|
||||||
|
: Array.isArray(batch && batch.changed_paths)
|
||||||
|
? batch.changed_paths
|
||||||
|
: [];
|
||||||
var affectedParents = Array.isArray(batch && batch.affectedParents)
|
var affectedParents = Array.isArray(batch && batch.affectedParents)
|
||||||
? batch.affectedParents
|
? batch.affectedParents
|
||||||
: Array.isArray(batch && batch.affected_parents)
|
: Array.isArray(batch && batch.affected_parents)
|
||||||
? batch.affected_parents
|
? batch.affected_parents
|
||||||
: [];
|
: [];
|
||||||
if (!affectedParents.length) {
|
if (!affectedParents.length) {
|
||||||
var changedPaths = Array.isArray(batch && batch.changedPaths)
|
|
||||||
? batch.changedPaths
|
|
||||||
: Array.isArray(batch && batch.changed_paths)
|
|
||||||
? batch.changed_paths
|
|
||||||
: [];
|
|
||||||
affectedParents = changedPaths.map(function(item) {
|
affectedParents = changedPaths.map(function(item) {
|
||||||
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim();
|
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim();
|
||||||
return { relativePath: parentRelativePathForPath(relativePath), reason: 'derived-from-changed-path' };
|
return { relativePath: parentRelativePathForPath(relativePath), reason: 'derived-from-changed-path' };
|
||||||
@@ -452,6 +452,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
affectedParents.forEach(function(parent) {
|
affectedParents.forEach(function(parent) {
|
||||||
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
||||||
});
|
});
|
||||||
|
var needsSidebarRefresh = changedPaths.some(function(item) {
|
||||||
|
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim().toLowerCase();
|
||||||
|
return relativePath.endsWith('.md') || relativePath.endsWith('.markdown');
|
||||||
|
});
|
||||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
|
||||||
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
||||||
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
||||||
@@ -459,6 +463,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
})).then(function() {
|
})).then(function() {
|
||||||
|
if (needsSidebarRefresh) {
|
||||||
|
void refreshLocalFolderSidebarSnapshot();
|
||||||
|
}
|
||||||
markLocalFolderWatchApplied('watch_batch');
|
markLocalFolderWatchApplied('watch_batch');
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -1391,6 +1398,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
if (runtimeFn) return runtimeFn(value);
|
if (runtimeFn) return runtimeFn(value);
|
||||||
var appliedValue = String(value || 'projection');
|
var appliedValue = String(value || 'projection');
|
||||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', appliedValue);
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', appliedValue);
|
||||||
|
document.documentElement.removeAttribute('data-mnote-local-folder-watch-disabled');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,55 +1500,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
if (!rootUri) return;
|
if (!rootUri) return;
|
||||||
ensureFileTreeLazyCacheScope();
|
ensureFileTreeLazyCacheScope();
|
||||||
scheduleRestorePersistedFileTreeExpansionState();
|
scheduleRestorePersistedFileTreeExpansionState();
|
||||||
var revision = '';
|
|
||||||
var refreshTimer = 0;
|
|
||||||
var treeLiveEventsActive = function() {
|
|
||||||
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
|
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
|
||||||
return treeTransport === 'local-folder-events';
|
if (treeTransport !== 'local-folder-events') {
|
||||||
};
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'static');
|
||||||
var scheduleRefresh = function() {
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-disabled', 'events-required');
|
||||||
if (treeLiveEventsActive()) return;
|
|
||||||
if (refreshTimer) return;
|
|
||||||
refreshTimer = window.setTimeout(function() {
|
|
||||||
refreshTimer = 0;
|
|
||||||
if (treeLiveEventsActive()) return;
|
|
||||||
var fileTreeScope = currentFileTreeScope();
|
|
||||||
if (fileTreeScope) {
|
|
||||||
markFileTreeParentStale(currentFileTreeParentKey(fileTreeScope));
|
|
||||||
markLocalFolderWatchApplied('scope_stale');
|
|
||||||
document.documentElement.setAttribute('data-mnote-filetree-scope-watch-stale', fileTreeScope);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
void refreshLocalFolderSidebarSnapshot();
|
|
||||||
}, 180);
|
|
||||||
};
|
|
||||||
var poll = async function() {
|
|
||||||
if (document.hidden) return;
|
|
||||||
// If tree live SSE transport is active for local_folder, skip polling (fallback)
|
|
||||||
if (treeLiveEventsActive()) return;
|
|
||||||
var url = new URL('/api/tree/local-folder-watch', window.location.origin);
|
|
||||||
url.searchParams.set('rootUri', rootUri);
|
|
||||||
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
|
||||||
if (!response.ok) return;
|
|
||||||
if (treeLiveEventsActive()) return;
|
|
||||||
var payload = await response.json();
|
|
||||||
var nextRevision = payload && payload.result && typeof payload.result.revision === 'string'
|
|
||||||
? payload.result.revision
|
|
||||||
: '';
|
|
||||||
if (!nextRevision) return;
|
|
||||||
if (!revision) {
|
|
||||||
revision = nextRevision;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextRevision !== revision) {
|
|
||||||
revision = nextRevision;
|
|
||||||
scheduleRefresh();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.setInterval(function() {
|
|
||||||
void poll();
|
|
||||||
}, 1200);
|
|
||||||
void poll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTitleOnlyDocumentPatch(candidate) {
|
function isTitleOnlyDocumentPatch(candidate) {
|
||||||
|
|||||||
@@ -961,6 +961,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadedAssetMarkdownHref(asset) {
|
||||||
|
var runtimeFn = localUploadRuntimeFunction('uploadedAssetMarkdownHref');
|
||||||
|
if (runtimeFn) return runtimeFn(asset);
|
||||||
|
var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim();
|
||||||
|
if (href) {
|
||||||
|
var normalizedHref = href.replace(/\\/g, '/');
|
||||||
|
if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return '';
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/');
|
||||||
|
if (!relativePath) return '';
|
||||||
|
if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return '';
|
||||||
|
if (relativePath.indexOf('./') === 0) return relativePath;
|
||||||
|
return './' + relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
function localAssetOpenUrl(asset, download) {
|
function localAssetOpenUrl(asset, download) {
|
||||||
var runtimeFn = localUploadRuntimeFunction('localAssetOpenUrl');
|
var runtimeFn = localUploadRuntimeFunction('localAssetOpenUrl');
|
||||||
if (runtimeFn) return runtimeFn(asset, download, { rootUri: currentRootUri() });
|
if (runtimeFn) return runtimeFn(asset, download, { rootUri: currentRootUri() });
|
||||||
@@ -1238,7 +1254,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
userId: '',
|
userId: '',
|
||||||
mode: 'view'
|
mode: 'view'
|
||||||
}));
|
}));
|
||||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
|
||||||
link.setAttribute('data-asset-id', detail.assetId);
|
link.setAttribute('data-asset-id', detail.assetId);
|
||||||
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
||||||
if (name) link.classList.add(name);
|
if (name) link.classList.add(name);
|
||||||
@@ -1733,7 +1748,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
var title = uploadedAssetTitle(asset);
|
var title = uploadedAssetTitle(asset);
|
||||||
var url = localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset);
|
var markdownHref = uploadedAssetMarkdownHref(asset);
|
||||||
|
var url = markdownHref || localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset);
|
||||||
var type = uploadedAssetType(asset);
|
var type = uploadedAssetType(asset);
|
||||||
var assetId = String(asset && asset.id || '').trim();
|
var assetId = String(asset && asset.id || '').trim();
|
||||||
var sizeLabel = uploadedFileSize(asset);
|
var sizeLabel = uploadedFileSize(asset);
|
||||||
@@ -1741,26 +1757,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
if (type === 'image' && url) {
|
if (type === 'image' && url) {
|
||||||
return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true;
|
return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true;
|
||||||
}
|
}
|
||||||
var isLocalAsset = isLocalUploadedAsset(asset);
|
var href = markdownHref || url;
|
||||||
var userId = '';
|
|
||||||
var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
|
||||||
if (onlyOfficeUrl && assetId) {
|
|
||||||
userId = await fetchCurrentOnlyOfficeUserId();
|
|
||||||
onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
|
||||||
}
|
|
||||||
var href = onlyOfficeUrl || url;
|
|
||||||
if (href) {
|
if (href) {
|
||||||
var storedHref = onlyOfficeUrl
|
|
||||||
? (isLocalAsset ? onlyOfficeUrl : buildOnlyOfficeOpenPath({
|
|
||||||
fileUrl: '',
|
|
||||||
fileName: title,
|
|
||||||
fileType: inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx',
|
|
||||||
assetId: assetId,
|
|
||||||
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
|
||||||
userId: userId || '',
|
|
||||||
mode: 'view'
|
|
||||||
}))
|
|
||||||
: href;
|
|
||||||
var inserted = editor.chain().focus().insertContent([
|
var inserted = editor.chain().focus().insertContent([
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
@@ -1770,7 +1768,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
marks: [{
|
marks: [{
|
||||||
type: 'link',
|
type: 'link',
|
||||||
attrs: {
|
attrs: {
|
||||||
href: storedHref,
|
href: href,
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
rel: 'noopener noreferrer nofollow',
|
rel: 'noopener noreferrer nofollow',
|
||||||
class: uploadedAttachmentClass(asset)
|
class: uploadedAttachmentClass(asset)
|
||||||
@@ -1806,7 +1804,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
}) || null;
|
}) || null;
|
||||||
}
|
}
|
||||||
if (link instanceof HTMLElement) {
|
if (link instanceof HTMLElement) {
|
||||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
|
||||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||||
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
||||||
} else if (Number(attempt) < 20) {
|
} else if (Number(attempt) < 20) {
|
||||||
@@ -2452,7 +2449,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]');
|
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openEditorAttachmentLink(editorAttachmentLink);
|
openEditorAttachmentLink(editorAttachmentLink);
|
||||||
@@ -2969,7 +2966,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('contextmenu', function(event) {
|
document.addEventListener('contextmenu', function(event) {
|
||||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]');
|
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -186,8 +186,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var sourceKind = (params.get('sourceKind') || '').trim();
|
var sourceKind = (params.get('sourceKind') || '').trim()
|
||||||
if (sourceKind === 'local_folder') {
|
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-source-kind') || '').trim() : '');
|
||||||
|
if (sourceKind === 'local_folder' || bootstrap.transport === 'local-folder-events') {
|
||||||
applyTransport('local-folder-events');
|
applyTransport('local-folder-events');
|
||||||
applyStatus('connecting');
|
applyStatus('connecting');
|
||||||
var localRootUri = (params.get('rootUri') || '').trim()
|
var localRootUri = (params.get('rootUri') || '').trim()
|
||||||
@@ -199,7 +200,7 @@
|
|||||||
startWithSse(bootstrap, '', url);
|
startWithSse(bootstrap, '', url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No rootUri or EventSource unavailable — mark static and let polling fallback handle it
|
// No rootUri or EventSource unavailable — stay static and let caller surface events-required
|
||||||
applyTransport('local-folder-static');
|
applyTransport('local-folder-static');
|
||||||
applyStatus('static');
|
applyStatus('static');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -979,6 +979,11 @@ function startTreeShellRuntime() {
|
|||||||
});
|
});
|
||||||
if (!response.ok) return false;
|
if (!response.ok) return false;
|
||||||
const nextState = parseTreeShellStateFromHtml(await response.text());
|
const nextState = parseTreeShellStateFromHtml(await response.text());
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
"data-mnote-local-folder-watch-applied",
|
||||||
|
options.appliedValue || "projection",
|
||||||
|
);
|
||||||
|
document.documentElement.removeAttribute("data-mnote-local-folder-watch-disabled");
|
||||||
return applyTreeShellStateSnapshot(nextState, options);
|
return applyTreeShellStateSnapshot(nextState, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -988,6 +993,16 @@ function startTreeShellRuntime() {
|
|||||||
}, 80);
|
}, 80);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let localFolderEventRefreshPending = false;
|
||||||
|
const scheduleLocalFolderEventRefresh = (options = {}) => {
|
||||||
|
if (localFolderEventRefreshPending) return;
|
||||||
|
localFolderEventRefreshPending = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
localFolderEventRefreshPending = false;
|
||||||
|
void refreshLocalFolderSnapshot(options);
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
const addTreeItemLocally = (item) => {
|
const addTreeItemLocally = (item) => {
|
||||||
if (!item?.nodeId || itemById.has(item.nodeId)) return false;
|
if (!item?.nodeId || itemById.has(item.nodeId)) return false;
|
||||||
normalizedItems.push(item);
|
normalizedItems.push(item);
|
||||||
@@ -1096,41 +1111,49 @@ function startTreeShellRuntime() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (sourceKind === "local_folder" && rootUri) {
|
if (sourceKind === "local_folder" && rootUri) {
|
||||||
let localWatchRevision = initialLocalWatchRevision;
|
let localFolderWatchRevision = initialLocalWatchRevision;
|
||||||
let localWatchRefreshTimer = 0;
|
const applyLocalFolderRevision = (revision) => {
|
||||||
const refreshFromLocalWatch = () => {
|
const nextRevision = normalizeText(revision);
|
||||||
if (localWatchRefreshTimer) return;
|
if (!nextRevision || nextRevision === localFolderWatchRevision) return false;
|
||||||
localWatchRefreshTimer = window.setTimeout(() => {
|
localFolderWatchRevision = nextRevision;
|
||||||
localWatchRefreshTimer = 0;
|
document.documentElement.setAttribute("data-mnote-tree-live-revision", nextRevision);
|
||||||
void refreshLocalFolderSnapshot();
|
return true;
|
||||||
}, 180);
|
|
||||||
};
|
};
|
||||||
const pollLocalFolderRevision = async () => {
|
window.addEventListener("tree:snapshot", function(event) {
|
||||||
if (busy || document.hidden) return;
|
const detail = event && event.detail ? event.detail : {};
|
||||||
const url = new URL("/api/tree/local-folder-watch", window.location.origin);
|
const payload = detail.payload || detail;
|
||||||
url.searchParams.set("rootUri", rootUri);
|
if (!payload || payload.sourceKind !== "local_folder") return;
|
||||||
const response = await fetch(url.toString(), { headers: { "accept": "application/json" } });
|
if (!applyLocalFolderRevision(payload.revision || detail.revision)) return;
|
||||||
if (!response.ok) return;
|
scheduleLocalFolderEventRefresh({ appliedValue: "snapshot" });
|
||||||
const payload = await response.json();
|
});
|
||||||
const nextRevision =
|
window.addEventListener("tree:resync", function(event) {
|
||||||
payload &&
|
const detail = event && event.detail ? event.detail : {};
|
||||||
payload.result &&
|
const payload = detail.payload || detail;
|
||||||
typeof payload.result.revision === "string"
|
if (!payload || payload.sourceKind !== "local_folder") return;
|
||||||
? payload.result.revision
|
if (!applyLocalFolderRevision(payload.revision || detail.revision)) return;
|
||||||
: "";
|
scheduleLocalFolderEventRefresh({ appliedValue: "resync" });
|
||||||
if (!nextRevision) return;
|
});
|
||||||
if (!localWatchRevision) {
|
window.addEventListener("tree:local-folder-watch-batch", function(event) {
|
||||||
localWatchRevision = nextRevision;
|
const detail = event && event.detail ? event.detail : {};
|
||||||
return;
|
const payload = detail.payload || detail;
|
||||||
|
if (!payload || payload.sourceKind !== "local_folder") return;
|
||||||
|
if (!applyLocalFolderRevision(payload.revision || detail.revision)) return;
|
||||||
|
scheduleLocalFolderEventRefresh({ appliedValue: "watch_batch" });
|
||||||
|
});
|
||||||
|
window.addEventListener("tree:error", function(event) {
|
||||||
|
const detail = event && event.detail ? event.detail : {};
|
||||||
|
const payload = detail.payload || detail;
|
||||||
|
if (!payload || payload.sourceKind !== "local_folder") return;
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
"data-mnote-tree-live-error",
|
||||||
|
normalizeText(payload.code || payload.error || payload.message, "tree_live_error"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || "";
|
||||||
|
if (transport !== "local-folder-events") {
|
||||||
|
document.documentElement.setAttribute("data-mnote-local-folder-watch-applied", "static");
|
||||||
|
document.documentElement.setAttribute("data-mnote-local-folder-watch-disabled", "events-required");
|
||||||
}
|
}
|
||||||
if (nextRevision !== localWatchRevision) {
|
|
||||||
localWatchRevision = nextRevision;
|
|
||||||
refreshFromLocalWatch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.setInterval(() => {
|
|
||||||
void pollLocalFolderRevision();
|
|
||||||
}, 1200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const readErrorMessage = async (response) => {
|
const readErrorMessage = async (response) => {
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ use crate::editor_actor::EditorRuntimeActor;
|
|||||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||||
use crate::middleware::request_context::inject_request_context;
|
use crate::middleware::request_context::inject_request_context;
|
||||||
use crate::routes::build_router;
|
use crate::routes::build_router;
|
||||||
|
use axum::extract::Request;
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::Response;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing::{error, warn};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@@ -192,10 +196,43 @@ fn open_control_plane_store() -> SqliteControlPlaneStore {
|
|||||||
|
|
||||||
pub fn build_app(state: AppState) -> Router {
|
pub fn build_app(state: AppState) -> Router {
|
||||||
build_router(state)
|
build_router(state)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http().on_failure(()))
|
||||||
|
.layer(axum::middleware::from_fn(log_failed_response))
|
||||||
.layer(axum::middleware::from_fn(inject_request_context))
|
.layer(axum::middleware::from_fn(inject_request_context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn log_failed_response(request: Request, next: Next) -> Response {
|
||||||
|
let method = request.method().clone();
|
||||||
|
let uri = request.uri().clone();
|
||||||
|
let response = next.run(request).await;
|
||||||
|
let status = response.status();
|
||||||
|
if status.is_server_error() {
|
||||||
|
let error_code = response
|
||||||
|
.headers()
|
||||||
|
.get("x-error-code")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if error_code == "convex_retired" {
|
||||||
|
warn!(
|
||||||
|
method = %method,
|
||||||
|
uri = %uri,
|
||||||
|
status = %status,
|
||||||
|
error_code = %error_code,
|
||||||
|
"退役 Convex 兼容路径被请求"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
error!(
|
||||||
|
method = %method,
|
||||||
|
uri = %uri,
|
||||||
|
status = %status,
|
||||||
|
error_code = %error_code,
|
||||||
|
"mnote-web 请求返回服务端错误"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -556,7 +556,10 @@ mod tests {
|
|||||||
Some("save:op:abc".into()),
|
Some("save:op:abc".into()),
|
||||||
)
|
)
|
||||||
.expect("保存后应更新 buffer");
|
.expect("保存后应更新 buffer");
|
||||||
assert_eq!(saved.last_write_intent_id.as_deref(), Some("intent:editor:abc"));
|
assert_eq!(
|
||||||
|
saved.last_write_intent_id.as_deref(),
|
||||||
|
Some("intent:editor:abc")
|
||||||
|
);
|
||||||
assert_eq!(saved.last_save_operation_id.as_deref(), Some("save:op:abc"));
|
assert_eq!(saved.last_save_operation_id.as_deref(), Some("save:op:abc"));
|
||||||
|
|
||||||
let outcome = store
|
let outcome = store
|
||||||
@@ -566,7 +569,10 @@ mod tests {
|
|||||||
Some("external-editor".into()),
|
Some("external-editor".into()),
|
||||||
)
|
)
|
||||||
.expect("watcher 回声应返回 outcome");
|
.expect("watcher 回声应返回 outcome");
|
||||||
assert!(outcome.self_write_echo, "同版本 watcher 回声应标记为自写回声");
|
assert!(
|
||||||
|
outcome.self_write_echo,
|
||||||
|
"同版本 watcher 回声应标记为自写回声"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outcome.buffer.last_write_intent_id.as_deref(),
|
outcome.buffer.last_write_intent_id.as_deref(),
|
||||||
Some("intent:editor:abc")
|
Some("intent:editor:abc")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::routes::{
|
|||||||
};
|
};
|
||||||
use notify::event::ModifyKind;
|
use notify::event::ModifyKind;
|
||||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
@@ -419,12 +419,12 @@ fn system_time_ms(time: SystemTime) -> u128 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
LocalFolderWatcherRegistry, is_local_search_index_path,
|
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind,
|
||||||
refresh_local_search_index_for_event, should_emit_event_kind,
|
LocalFolderWatcherRegistry,
|
||||||
};
|
};
|
||||||
use crate::document_buffer_store::BufferStore;
|
use crate::document_buffer_store::BufferStore;
|
||||||
use notify::EventKind;
|
|
||||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||||
|
use notify::EventKind;
|
||||||
|
|
||||||
fn test_root(name: &str) -> std::path::PathBuf {
|
fn test_root(name: &str) -> std::path::PathBuf {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ impl PageAggregateBuilder {
|
|||||||
block_document: block_document.unwrap_or(Value::Null),
|
block_document: block_document.unwrap_or(Value::Null),
|
||||||
block_projection_version,
|
block_projection_version,
|
||||||
projection_source: "builder.content".into(),
|
projection_source: "builder.content".into(),
|
||||||
|
attachment_refs: Value::Array(Vec::new()),
|
||||||
},
|
},
|
||||||
tree: PageTree {
|
tree: PageTree {
|
||||||
page_subtree: self.page_subtree,
|
page_subtree: self.page_subtree,
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ use crate::error::WebError;
|
|||||||
use crate::routes::query_support::{
|
use crate::routes::query_support::{
|
||||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -152,7 +152,7 @@ pub async fn trace(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ use crate::app::{AppConfig, AppState};
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::transport::convex::{
|
use crate::transport::convex::{
|
||||||
RetiredCloudCommandExecution, execute_retired_command_plan,
|
execute_retired_command_plan, execute_retired_command_plan_with_artifacts,
|
||||||
execute_retired_command_plan_with_artifacts,
|
RetiredCloudCommandExecution,
|
||||||
};
|
};
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||||
RuntimeTargetWire, execute_runtime_input,
|
RuntimeTargetWire,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
pub fn runtime_context(
|
pub fn runtime_context(
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::Json;
|
|
||||||
use axum::http::{HeaderMap, HeaderValue};
|
use axum::http::{HeaderMap, HeaderValue};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -34,6 +34,10 @@ pub fn dev_hot_reload_enabled() -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn dev_hot_cache_buster() -> Option<&'static str> {
|
||||||
|
dev_hot_reload_enabled().then_some(DEV_HOT_BOOT_ID.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn hot_reload() -> impl IntoResponse {
|
pub async fn hot_reload() -> impl IntoResponse {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ use crate::routes::query_support::{
|
|||||||
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
|
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
|
||||||
resolve_effective_workspace_id,
|
resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -1036,10 +1036,9 @@ pub async fn title(
|
|||||||
}),
|
}),
|
||||||
preflight_data: None,
|
preflight_data: None,
|
||||||
reason: Some("mnote-web page title update".into()),
|
reason: Some("mnote-web page title update".into()),
|
||||||
refs: vec![
|
refs: vec![body
|
||||||
body.command_name
|
.command_name
|
||||||
.unwrap_or_else(|| "page.head.updateTitle".into()),
|
.unwrap_or_else(|| "page.head.updateTitle".into())],
|
||||||
],
|
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
@@ -1141,10 +1140,9 @@ pub async fn options(
|
|||||||
}),
|
}),
|
||||||
preflight_data: None,
|
preflight_data: None,
|
||||||
reason: Some("mnote-web page layout update".into()),
|
reason: Some("mnote-web page layout update".into()),
|
||||||
refs: vec![
|
refs: vec![body
|
||||||
body.command_name
|
.command_name
|
||||||
.unwrap_or_else(|| "page.layout.updateOptions".into()),
|
.unwrap_or_else(|| "page.layout.updateOptions".into())],
|
||||||
],
|
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
@@ -1181,9 +1179,9 @@ pub async fn options(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
|
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
@@ -1515,7 +1513,8 @@ mod tests {
|
|||||||
"upsert_document"
|
"upsert_document"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
|
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
|
||||||
|
["title"],
|
||||||
"服务端页面(改名)"
|
"服务端页面(改名)"
|
||||||
);
|
);
|
||||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||||
@@ -1796,19 +1795,15 @@ mod tests {
|
|||||||
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
|
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
|
||||||
Some(stale_file_version.as_str())
|
Some(stale_file_version.as_str())
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(payload["details"]["conflict"]["currentDiskVersion"]
|
||||||
payload["details"]["conflict"]["currentDiskVersion"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.starts_with("local-md:local-md:README.md:")
|
.starts_with("local-md:local-md:README.md:"));
|
||||||
);
|
assert!(payload["details"]["conflict"]["suggestedActions"]
|
||||||
assert!(
|
|
||||||
payload["details"]["conflict"]["suggestedActions"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|action| action.as_str() == Some("merge"))
|
.any(|action| action.as_str() == Some("merge")));
|
||||||
);
|
|
||||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||||
assert!(markdown.contains("# External"));
|
assert!(markdown.contains("# External"));
|
||||||
assert!(!markdown.contains("# Editor"));
|
assert!(!markdown.contains("# Editor"));
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::documents::{
|
use crate::routes::documents::{
|
||||||
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
|
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
|
||||||
};
|
};
|
||||||
use axum::extract::{Extension, Json, Query, State};
|
use axum::extract::{Extension, Json, Query, State};
|
||||||
use axum::http::{HeaderValue, StatusCode, header};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -1763,10 +1763,10 @@ pub async fn transform_runtime_snapshot(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
fn app() -> axum::Router {
|
fn app() -> axum::Router {
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ use crate::workspace_shell::{
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri, header};
|
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use control_plane::{
|
use control_plane::{
|
||||||
AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||||
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash,
|
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput,
|
||||||
};
|
};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use leptos::prelude::InnerHtmlAttribute;
|
use leptos::prelude::InnerHtmlAttribute;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
@@ -560,6 +560,7 @@ pub async fn root_entry(
|
|||||||
active_page_title={active_page_title.clone()}
|
active_page_title={active_page_title.clone()}
|
||||||
navigation_html={navigation_html.clone().unwrap_or_default()}
|
navigation_html={navigation_html.clone().unwrap_or_default()}
|
||||||
show_admin_access_policy={show_admin_access_policy}
|
show_admin_access_policy={show_admin_access_policy}
|
||||||
|
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -622,7 +623,7 @@ pub async fn root_entry(
|
|||||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||||
page_subtree_json={page_subtree_json}
|
page_subtree_json={page_subtree_json}
|
||||||
show_admin_access_policy={show_admin_access_policy}
|
show_admin_access_policy={show_admin_access_policy}
|
||||||
enable_tree_live={active_source_kind.as_deref() != Some("local_folder")}
|
enable_tree_live={true}
|
||||||
/>
|
/>
|
||||||
});
|
});
|
||||||
let body_extra = format!(
|
let body_extra = format!(
|
||||||
@@ -645,7 +646,7 @@ pub async fn root_entry(
|
|||||||
let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") {
|
let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") {
|
||||||
render_editor_runtime_preload_links()
|
render_editor_runtime_preload_links()
|
||||||
} else {
|
} else {
|
||||||
""
|
String::new()
|
||||||
};
|
};
|
||||||
let mut response = Html(format!(
|
let mut response = Html(format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
@@ -2448,9 +2449,9 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
|
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
||||||
@@ -3019,7 +3020,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
|
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
|
||||||
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
|
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||||
|
|
||||||
let app_state = AppState::new(AppConfig {
|
let app_state = AppState::new(AppConfig {
|
||||||
service_name: "mnote-web".into(),
|
service_name: "mnote-web".into(),
|
||||||
@@ -3486,9 +3487,7 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
|
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
|
||||||
assert!(
|
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
|
||||||
html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
||||||
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
|
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
|
||||||
@@ -3672,14 +3671,12 @@ mod tests {
|
|||||||
.and_then(|value| value.to_str().ok()),
|
.and_then(|value| value.to_str().ok()),
|
||||||
Some("mnote-web")
|
Some("mnote-web")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(response
|
||||||
response
|
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("text/html")
|
.contains("text/html"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3734,16 +3731,12 @@ mod tests {
|
|||||||
.map(|value| value.to_str().unwrap_or_default())
|
.map(|value| value.to_str().unwrap_or_default())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(values.iter().any(|value| value.contains("mnote_session=")));
|
assert!(values.iter().any(|value| value.contains("mnote_session=")));
|
||||||
assert!(
|
assert!(values
|
||||||
values
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.contains("mnote_actor_id=new-user"))
|
.any(|value| value.contains("mnote_actor_id=new-user")));
|
||||||
);
|
assert!(values
|
||||||
assert!(
|
|
||||||
values
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.contains("mnote_actor_type=user"))
|
.any(|value| value.contains("mnote_actor_type=user")));
|
||||||
);
|
|
||||||
let body = to_bytes(response.into_body(), usize::MAX)
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.expect("body");
|
.expect("body");
|
||||||
@@ -3822,12 +3815,10 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||||
assert_eq!(payload["code"], "auth_signup_email_required");
|
assert_eq!(payload["code"], "auth_signup_email_required");
|
||||||
assert!(
|
assert!(payload["message"]
|
||||||
payload["message"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("注册账号时请填写邮箱")
|
.contains("注册账号时请填写邮箱"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3851,11 +3842,9 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|value| value.to_str().unwrap_or_default())
|
.map(|value| value.to_str().unwrap_or_default())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(
|
assert!(values
|
||||||
values
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))
|
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3961,16 +3950,12 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|value| value.to_str().unwrap_or_default())
|
.map(|value| value.to_str().unwrap_or_default())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(
|
assert!(!values
|
||||||
!values
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.contains("__convexAuthJWT="))
|
.any(|value| value.contains("__convexAuthJWT=")));
|
||||||
);
|
assert!(!values
|
||||||
assert!(
|
|
||||||
!values
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.contains("__convexAuthRefreshToken="))
|
.any(|value| value.contains("__convexAuthRefreshToken=")));
|
||||||
);
|
|
||||||
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
|
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
|
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||||
execute_runtime_query, runtime_input_requests_result,
|
runtime_input_requests_result, RuntimeInput,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||||
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
|
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
|
||||||
@@ -150,10 +150,10 @@ fn stamp_ai_bridge_headers() -> HeaderMap {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
fn app() -> axum::Router {
|
fn app() -> axum::Router {
|
||||||
@@ -253,12 +253,10 @@ mod tests {
|
|||||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||||
assert!(
|
assert!(payload["eventStreamEndpoint"]
|
||||||
payload["eventStreamEndpoint"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("/api/hermes/events/")
|
.contains("/api/hermes/events/"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ use crate::context::RequestContext;
|
|||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::manifest;
|
use crate::hermes_tools::manifest;
|
||||||
use crate::transport::convex::{execute_retired_mutation_by_name, execute_retired_query_by_name};
|
use crate::transport::convex::{execute_retired_mutation_by_name, execute_retired_query_by_name};
|
||||||
use axum::Json;
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Extension, Path, Query, State};
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
|
use axum::Json;
|
||||||
use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput};
|
use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput};
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -2230,7 +2230,11 @@ fn profile_home(profile: &str) -> PathBuf {
|
|||||||
return home;
|
return home;
|
||||||
}
|
}
|
||||||
let candidate = home.join("profiles").join(profile);
|
let candidate = home.join("profiles").join(profile);
|
||||||
if candidate.exists() { candidate } else { home }
|
if candidate.exists() {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
home
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_config_path(profile: &str) -> PathBuf {
|
fn profile_config_path(profile: &str) -> PathBuf {
|
||||||
@@ -6325,8 +6329,8 @@ fn stamp_client_headers_into(headers: &mut HeaderMap) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use control_plane::{DirectoryGrantInput, UpsertAiRuntimeRunInput, UpsertUserInput};
|
use control_plane::{DirectoryGrantInput, UpsertAiRuntimeRunInput, UpsertUserInput};
|
||||||
@@ -6379,16 +6383,12 @@ mod tests {
|
|||||||
let files = changed.as_array().expect("changed files");
|
let files = changed.as_array().expect("changed files");
|
||||||
|
|
||||||
assert_eq!(files.len(), 2);
|
assert_eq!(files.len(), 2);
|
||||||
assert!(
|
assert!(files
|
||||||
files
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" })
|
.any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" }));
|
||||||
);
|
assert!(files
|
||||||
assert!(
|
|
||||||
files
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|file| { file["path"] == "b.md" && file["changeType"] == "added" })
|
.any(|file| { file["path"] == "b.md" && file["changeType"] == "added" }));
|
||||||
);
|
|
||||||
assert!(!files.iter().any(|file| {
|
assert!(!files.iter().any(|file| {
|
||||||
file["path"]
|
file["path"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -6424,11 +6424,9 @@ mod tests {
|
|||||||
assert!(files.iter().any(|file| {
|
assert!(files.iter().any(|file| {
|
||||||
file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified"
|
file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified"
|
||||||
}));
|
}));
|
||||||
assert!(
|
assert!(files
|
||||||
files.iter().any(|file| {
|
.iter()
|
||||||
file["path"] == "office/a.docx" && file["changeType"] == "modified"
|
.any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" }));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -8469,10 +8467,8 @@ mod tests {
|
|||||||
let instructions = body["instructions"].as_str().expect("instructions");
|
let instructions = body["instructions"].as_str().expect("instructions");
|
||||||
assert!(instructions.contains("\"sourceKind\":\"local_folder\""));
|
assert!(instructions.contains("\"sourceKind\":\"local_folder\""));
|
||||||
assert!(instructions.contains("\"fileReference\""));
|
assert!(instructions.contains("\"fileReference\""));
|
||||||
assert!(
|
assert!(instructions
|
||||||
instructions
|
.contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\""));
|
||||||
.contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\"")
|
|
||||||
);
|
|
||||||
assert!(instructions.contains("\"aiAccessScope\""));
|
assert!(instructions.contains("\"aiAccessScope\""));
|
||||||
assert!(instructions.contains("\"allowedRoots\""));
|
assert!(instructions.contains("\"allowedRoots\""));
|
||||||
assert!(instructions.contains("\"editorTarget\""));
|
assert!(instructions.contains("\"editorTarget\""));
|
||||||
@@ -8493,10 +8489,8 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
!instructions.contains("恶意 runTargetSnapshot.editorTarget 正文不应进入 instructions")
|
!instructions.contains("恶意 runTargetSnapshot.editorTarget 正文不应进入 instructions")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(!instructions
|
||||||
!instructions
|
.contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions"));
|
||||||
.contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions")
|
|
||||||
);
|
|
||||||
assert!(!instructions.contains("\"contextBlocks\""));
|
assert!(!instructions.contains("\"contextBlocks\""));
|
||||||
assert!(!instructions.contains("\"pageXml\""));
|
assert!(!instructions.contains("\"pageXml\""));
|
||||||
assert!(!instructions.contains("\"pageText\""));
|
assert!(!instructions.contains("\"pageText\""));
|
||||||
@@ -8595,11 +8589,10 @@ mod tests {
|
|||||||
env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
|
env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
|
||||||
Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间")
|
Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(!env
|
||||||
!env.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
|
.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("/mnt/Data1T/Mnote_data/users/user_2")
|
.contains("/mnt/Data1T/Mnote_data/users/user_2"));
|
||||||
);
|
|
||||||
let scope = serde_json::from_str::<serde_json::Value>(
|
let scope = serde_json::from_str::<serde_json::Value>(
|
||||||
env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
|
env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
|
||||||
)
|
)
|
||||||
@@ -8669,12 +8662,10 @@ mod tests {
|
|||||||
payload["allowedRoots"][0]["source"],
|
payload["allowedRoots"][0]["source"],
|
||||||
"sqlite_directory_grant"
|
"sqlite_directory_grant"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(payload["allowedRoots"][0]["grantIds"]
|
||||||
payload["allowedRoots"][0]["grantIds"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.map(|items| !items.is_empty())
|
.map(|items| !items.is_empty())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false));
|
||||||
);
|
|
||||||
assert!(!payload.to_string().contains("file:///tmp/evil"));
|
assert!(!payload.to_string().contains("file:///tmp/evil"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use super::hermes_client;
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::{ToolCallInput, artifact, block, doc, manifest, page, resource};
|
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
@@ -827,10 +827,10 @@ fn stamp_tool_headers() -> HeaderMap {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -1112,56 +1112,36 @@ mod tests {
|
|||||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
|
||||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
|
||||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
|
||||||
assert!(
|
assert!(tools
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.doc.plan_update")
|
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.block.replace")
|
.any(|tool| tool["name"] == "mnote.block.replace"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.block.insert_after")
|
.any(|tool| tool["name"] == "mnote.block.insert_after"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.block.delete")
|
.any(|tool| tool["name"] == "mnote.block.delete"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.block.move_after")
|
.any(|tool| tool["name"] == "mnote.block.move_after"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops")
|
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.mindmap.fetch")
|
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops")
|
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.office.fetch_summary")
|
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
|
||||||
);
|
assert!(tools
|
||||||
assert!(
|
|
||||||
tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool["name"] == "mnote.office.propose_changes")
|
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
|
||||||
);
|
|
||||||
let page_save = tools
|
let page_save = tools
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tool| tool["name"] == "mnote.page.save")
|
.find(|tool| tool["name"] == "mnote.page.save")
|
||||||
@@ -1209,20 +1189,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
|
assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
|
||||||
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
|
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
|
||||||
assert!(
|
assert!(markdown_edit["inputSchema"]["anyOf"]
|
||||||
markdown_edit["inputSchema"]["anyOf"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("anyOf")
|
.expect("anyOf")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|rule| rule["required"] == json!(["operations"]))
|
.any(|rule| rule["required"] == json!(["operations"])));
|
||||||
);
|
assert!(markdown_edit["inputSchema"]["anyOf"]
|
||||||
assert!(
|
|
||||||
markdown_edit["inputSchema"]["anyOf"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("anyOf")
|
.expect("anyOf")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|rule| rule["required"] == json!(["full_content"]))
|
.any(|rule| rule["required"] == json!(["full_content"])));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1252,18 +1228,14 @@ mod tests {
|
|||||||
.find(|tool| tool["name"] == "mnote.page.save")
|
.find(|tool| tool["name"] == "mnote.page.save")
|
||||||
.expect("page save tool");
|
.expect("page save tool");
|
||||||
|
|
||||||
assert!(
|
assert!(markdown_edit["description"]
|
||||||
markdown_edit["description"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("description")
|
.expect("description")
|
||||||
.contains("兼容")
|
.contains("兼容"));
|
||||||
);
|
assert!(markdown_edit["description"]
|
||||||
assert!(
|
|
||||||
markdown_edit["description"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("description")
|
.expect("description")
|
||||||
.contains("agent 原生 patch/diff")
|
.contains("agent 原生 patch/diff"));
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page_save["annotations"]["requiresWritePermission"],
|
page_save["annotations"]["requiresWritePermission"],
|
||||||
Value::Bool(true)
|
Value::Bool(true)
|
||||||
@@ -1454,12 +1426,10 @@ mod tests {
|
|||||||
payload["result"]["objectIdentity"],
|
payload["result"]["objectIdentity"],
|
||||||
"resource:mindmap:local-md:README.md:mind_allowed"
|
"resource:mindmap:local-md:README.md:mind_allowed"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(payload["result"]["markdownSummary"]
|
||||||
payload["result"]["markdownSummary"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("中心主题")
|
.contains("中心主题"));
|
||||||
);
|
|
||||||
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
|
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -1795,12 +1765,10 @@ mod tests {
|
|||||||
assert_eq!(payload["toolName"], "mnote.page.get");
|
assert_eq!(payload["toolName"], "mnote.page.get");
|
||||||
assert_eq!(payload["toolCallId"], "call_1");
|
assert_eq!(payload["toolCallId"], "call_1");
|
||||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||||
assert!(
|
assert!(payload["result"]["bodySummary"]
|
||||||
payload["result"]["bodySummary"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("章节一")
|
.contains("章节一"));
|
||||||
);
|
|
||||||
assert_eq!(payload["audit"]["effect"], "read");
|
assert_eq!(payload["audit"]["effect"], "read");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1846,12 +1814,10 @@ mod tests {
|
|||||||
json!("heading_1")
|
json!("heading_1")
|
||||||
);
|
);
|
||||||
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
|
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
|
||||||
assert!(
|
assert!(payload["result"]["blocks"][0]["revisionRef"]
|
||||||
payload["result"]["blocks"][0]["revisionRef"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.starts_with("pageRev:7:block:heading_1:hash:")
|
.starts_with("pageRev:7:block:heading_1:hash:"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1978,12 +1944,10 @@ mod tests {
|
|||||||
assert_eq!(payload["result"]["scope"], "selection");
|
assert_eq!(payload["result"]["scope"], "selection");
|
||||||
assert_eq!(payload["result"]["format"], "page_xml");
|
assert_eq!(payload["result"]["format"], "page_xml");
|
||||||
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
|
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
|
||||||
assert!(
|
assert!(payload["result"]["content"]
|
||||||
payload["result"]["content"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("<block id=\"heading_1\"")
|
.contains("<block id=\"heading_1\""));
|
||||||
);
|
|
||||||
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
|
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2246,12 +2210,10 @@ mod tests {
|
|||||||
insert_payload["result"]["changedBlocks"][0]["op"],
|
insert_payload["result"]["changedBlocks"][0]["op"],
|
||||||
json!("insert_after")
|
json!("insert_after")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
|
||||||
insert_payload["result"]["changedBlocks"][0]["blockId"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.starts_with("ai_block_")
|
.starts_with("ai_block_"));
|
||||||
);
|
|
||||||
|
|
||||||
let delete_response = app()
|
let delete_response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -2603,11 +2565,9 @@ mod tests {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.expect("insertedBlockIds");
|
.expect("insertedBlockIds");
|
||||||
assert_eq!(inserted_ids.len(), 2);
|
assert_eq!(inserted_ids.len(), 2);
|
||||||
assert!(
|
assert!(inserted_ids
|
||||||
inserted_ids
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") })
|
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["result"]["changedBlocks"]
|
payload["result"]["changedBlocks"]
|
||||||
.as_array()
|
.as_array()
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ use crate::routes::local_folder_source::{
|
|||||||
};
|
};
|
||||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||||
use crate::routes::snapshot_support::{
|
use crate::routes::snapshot_support::{
|
||||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||||
subtree_query,
|
ProjectionSnapshotSpec,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||||
@@ -264,9 +264,9 @@ pub async fn graph(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -409,8 +409,7 @@ mod tests {
|
|||||||
assert_eq!(payload["ok"], true);
|
assert_eq!(payload["ok"], true);
|
||||||
assert_eq!(payload["result"]["projection"], "file_tree");
|
assert_eq!(payload["result"]["projection"], "file_tree");
|
||||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||||
assert!(
|
assert!(payload["result"]["items"]
|
||||||
payload["result"]["items"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("items")
|
.expect("items")
|
||||||
.iter()
|
.iter()
|
||||||
@@ -420,8 +419,7 @@ mod tests {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.starts_with("local-md:")
|
.starts_with("local-md:")
|
||||||
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")
|
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面"));
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -515,11 +513,9 @@ mod tests {
|
|||||||
assert!(titles.contains(&"README.md"));
|
assert!(titles.contains(&"README.md"));
|
||||||
assert!(titles.contains(&"nested"));
|
assert!(titles.contains(&"nested"));
|
||||||
assert!(!titles.contains(&"deep.md"));
|
assert!(!titles.contains(&"deep.md"));
|
||||||
assert!(
|
assert!(items
|
||||||
items
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs"))
|
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs")));
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -680,13 +676,11 @@ mod tests {
|
|||||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
|
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
|
||||||
"mindmap"
|
"mindmap"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||||
item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("capabilities")
|
.expect("capabilities")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value == "expand")
|
.any(|value| value == "expand"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ use crate::routes::snapshot_support::ProjectionSnapshot;
|
|||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||||
use axum::response::sse::{Event as SseEvent, Sse};
|
use axum::response::sse::{Event as SseEvent, Sse};
|
||||||
use futures_util::StreamExt;
|
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
|
use futures_util::StreamExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
use tokio::time::{MissedTickBehavior, interval, timeout};
|
use tokio::time::timeout;
|
||||||
|
|
||||||
type BoxedEventStream =
|
type BoxedEventStream =
|
||||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||||
@@ -70,7 +70,12 @@ async fn build_document_events_stream(
|
|||||||
.document_id
|
.document_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(local_markdown_relative_path_from_document_id)
|
.and_then(local_markdown_relative_path_from_document_id)
|
||||||
.or_else(|| query.resource_path.as_deref().and_then(normalize_resource_event_path));
|
.or_else(|| {
|
||||||
|
query
|
||||||
|
.resource_path
|
||||||
|
.as_deref()
|
||||||
|
.and_then(normalize_resource_event_path)
|
||||||
|
});
|
||||||
let subscription = state
|
let subscription = state
|
||||||
.local_folder_watcher_registry()
|
.local_folder_watcher_registry()
|
||||||
.subscribe(&canonical_root)
|
.subscribe(&canonical_root)
|
||||||
@@ -147,26 +152,15 @@ async fn build_tree_live_stream(
|
|||||||
&file_tree_snapshot,
|
&file_tree_snapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
let subscription = match state
|
let subscription = state
|
||||||
.local_folder_watcher_registry()
|
.local_folder_watcher_registry()
|
||||||
.subscribe(&canonical_root)
|
.subscribe(&canonical_root)
|
||||||
{
|
.map_err(|error| {
|
||||||
Ok(subscription) => subscription,
|
WebError::internal(format!(
|
||||||
Err(error) => {
|
"local_folder tree live watcher unavailable: {error}"
|
||||||
tracing::warn!(
|
))
|
||||||
error = %error,
|
.with_context(&context)
|
||||||
root_uri = %root_uri,
|
})?;
|
||||||
"local_folder tree live watcher unavailable; falling back to revision polling"
|
|
||||||
);
|
|
||||||
let stream = build_tree_live_polling_stream(
|
|
||||||
root_uri,
|
|
||||||
workspace_id,
|
|
||||||
revision.revision,
|
|
||||||
initial_payload,
|
|
||||||
);
|
|
||||||
return Ok((HeaderMap::new(), stream));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let stream = stream::unfold(
|
let stream = stream::unfold(
|
||||||
(Some(initial_payload), subscription, root_uri, workspace_id),
|
(Some(initial_payload), subscription, root_uri, workspace_id),
|
||||||
@@ -224,81 +218,6 @@ async fn build_tree_live_stream(
|
|||||||
Ok((HeaderMap::new(), stream))
|
Ok((HeaderMap::new(), stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_tree_live_polling_stream(
|
|
||||||
root_uri: String,
|
|
||||||
workspace_id: String,
|
|
||||||
initial_revision: String,
|
|
||||||
initial_payload: Value,
|
|
||||||
) -> BoxedEventStream {
|
|
||||||
let mut poll_interval = interval(Duration::from_millis(1_200));
|
|
||||||
poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
|
||||||
|
|
||||||
stream::unfold(
|
|
||||||
Some(TreeLivePollingState {
|
|
||||||
root_uri,
|
|
||||||
workspace_id,
|
|
||||||
current_revision: initial_revision,
|
|
||||||
initial_payload: Some(initial_payload),
|
|
||||||
poll_interval,
|
|
||||||
}),
|
|
||||||
|state| async move {
|
|
||||||
let mut state = state?;
|
|
||||||
if let Some(payload) = state.initial_payload.take() {
|
|
||||||
return Some((Ok(stream_event("snapshot", &payload)), Some(state)));
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
state.poll_interval.tick().await;
|
|
||||||
let Some((next_revision, resync_payload)) = tree_live_polling_resync_payload(
|
|
||||||
&state.root_uri,
|
|
||||||
&state.workspace_id,
|
|
||||||
&state.current_revision,
|
|
||||||
) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
state.current_revision = next_revision;
|
|
||||||
return Some((Ok(stream_event("resync", &resync_payload)), Some(state)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.boxed()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TreeLivePollingState {
|
|
||||||
root_uri: String,
|
|
||||||
workspace_id: String,
|
|
||||||
current_revision: String,
|
|
||||||
initial_payload: Option<Value>,
|
|
||||||
poll_interval: tokio::time::Interval,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tree_live_polling_resync_payload(
|
|
||||||
root_uri: &str,
|
|
||||||
workspace_id: &str,
|
|
||||||
current_revision: &str,
|
|
||||||
) -> Option<(String, Value)> {
|
|
||||||
let next_revision = local_folder_watch_revision(root_uri).ok()?;
|
|
||||||
if next_revision.revision == current_revision {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let resync_payload = rebuild_tree_resync_payload(root_uri, workspace_id)?;
|
|
||||||
Some((next_revision.revision, resync_payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Value> {
|
|
||||||
let revision = local_folder_watch_revision(root_uri).ok()?;
|
|
||||||
let sidebar_snapshot = load_local_folder_page_tree_snapshot(root_uri).ok()?;
|
|
||||||
let file_tree_snapshot = load_local_folder_file_tree_snapshot(root_uri).ok()?;
|
|
||||||
Some(build_tree_snapshot_payload(
|
|
||||||
root_uri,
|
|
||||||
workspace_id,
|
|
||||||
&revision.revision,
|
|
||||||
"resync",
|
|
||||||
&sidebar_snapshot,
|
|
||||||
&file_tree_snapshot,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parent_relative_path_for_watch_path(relative_path: &str) -> String {
|
fn parent_relative_path_for_watch_path(relative_path: &str) -> String {
|
||||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||||
if normalized.is_empty() || normalized == "." {
|
if normalized.is_empty() || normalized == "." {
|
||||||
@@ -472,7 +391,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
@@ -614,7 +533,10 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert!(document_event_targets_relative_path(&payload, &expected));
|
assert!(document_event_targets_relative_path(&payload, &expected));
|
||||||
assert!(!document_event_targets_relative_path(&other_payload, &expected));
|
assert!(!document_event_targets_relative_path(
|
||||||
|
&other_payload,
|
||||||
|
&expected
|
||||||
|
));
|
||||||
assert!(
|
assert!(
|
||||||
normalize_resource_event_path("../escape.pdf").is_none(),
|
normalize_resource_event_path("../escape.pdf").is_none(),
|
||||||
"resource watch path 不能越过 root"
|
"resource watch path 不能越过 root"
|
||||||
@@ -669,40 +591,6 @@ mod tests {
|
|||||||
assert!(payload["data"]["tree"].is_object());
|
assert!(payload["data"]["tree"].is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_live_polling_resync_payload_tracks_revision_changes() {
|
|
||||||
let root = test_root("tree-live-polling-resync");
|
|
||||||
std::fs::write(root.join("README.md"), "# Initial\n").expect("write initial");
|
|
||||||
let root_uri = format!("file://{}", root.display());
|
|
||||||
let workspace_id =
|
|
||||||
local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id");
|
|
||||||
let initial_revision = local_folder_watch_revision(&root_uri)
|
|
||||||
.expect("initial revision")
|
|
||||||
.revision;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
tree_live_polling_resync_payload(&root_uri, &workspace_id, &initial_revision).is_none(),
|
|
||||||
"revision 未变化时不应发送 resync"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
||||||
std::fs::write(root.join("docs/new.md"), "# New\n").expect("write new markdown");
|
|
||||||
let (next_revision, payload) =
|
|
||||||
tree_live_polling_resync_payload(&root_uri, &workspace_id, &initial_revision)
|
|
||||||
.expect("revision change should build resync payload");
|
|
||||||
|
|
||||||
assert_ne!(next_revision, initial_revision);
|
|
||||||
assert_eq!(payload["kind"], "resync");
|
|
||||||
assert_eq!(payload["sourceKind"], "local_folder");
|
|
||||||
assert_eq!(payload["rootUri"], root_uri);
|
|
||||||
assert_eq!(payload["workspaceId"], workspace_id);
|
|
||||||
assert_eq!(payload["revision"], next_revision);
|
|
||||||
assert!(payload["data"]["dataset"]["kernel_sidebar_projection"].is_object());
|
|
||||||
assert!(payload["data"]["dataset"]["kernel_file_tree_projection"].is_object());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() {
|
fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() {
|
||||||
let root = test_root("tree-live-watch-batch");
|
let root = test_root("tree-live-watch-batch");
|
||||||
@@ -741,13 +629,11 @@ mod tests {
|
|||||||
&& parent["reason"].as_str() == Some("child-watch")),
|
&& parent["reason"].as_str() == Some("child-watch")),
|
||||||
"watch batch 应声明 docs affected parent: {payload}"
|
"watch batch 应声明 docs affected parent: {payload}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(payload["eventKinds"]
|
||||||
payload["eventKinds"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("event kinds")
|
.expect("event kinds")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|kind| kind.as_str() == Some("Modify(Data)"))
|
.any(|kind| kind.as_str() == Some("Modify(Data)")));
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ use crate::page_aggregate::{
|
|||||||
PagePermissions, PageStats, PageTree,
|
PagePermissions, PageStats, PageTree,
|
||||||
};
|
};
|
||||||
use crate::routes::local_markdown_parser::{
|
use crate::routes::local_markdown_parser::{
|
||||||
file_stem_title, parse_markdown_page, split_frontmatter,
|
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
|
||||||
};
|
};
|
||||||
use crate::routes::local_search_index;
|
use crate::routes::local_search_index;
|
||||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::project_legacy_content_to_block_document;
|
use bridge_runtime::project_legacy_content_to_block_document;
|
||||||
use control_plane::AppendAuditInput;
|
use control_plane::AppendAuditInput;
|
||||||
use control_plane::{
|
use control_plane::{
|
||||||
@@ -24,7 +24,7 @@ use core_protocol::{
|
|||||||
};
|
};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{json, Map, Value};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
@@ -55,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
|
|||||||
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
|
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
|
||||||
> = OnceLock::new();
|
> = OnceLock::new();
|
||||||
|
|
||||||
fn local_page_tree_snapshot_cache()
|
fn local_page_tree_snapshot_cache(
|
||||||
-> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
||||||
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
|
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2966,6 +2966,7 @@ pub fn resolve_local_markdown_page_aggregate(
|
|||||||
"本地 rootUri 必须指向目录",
|
"本地 rootUri 必须指向目录",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let root_source_uri = file_uri_for_path(&canonical_root);
|
||||||
let metadata = load_local_folder_metadata(&canonical_root)?;
|
let metadata = load_local_folder_metadata(&canonical_root)?;
|
||||||
let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
|
let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -2995,6 +2996,20 @@ pub fn resolve_local_markdown_page_aggregate(
|
|||||||
&parsed.body,
|
&parsed.body,
|
||||||
&attachment_paths,
|
&attachment_paths,
|
||||||
);
|
);
|
||||||
|
let attachment_refs = parse_markdown_attachment_refs(
|
||||||
|
&parsed.body,
|
||||||
|
&markdown_file.path.display().to_string(),
|
||||||
|
&root_source_uri,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut attachment_ref| {
|
||||||
|
if let Some(path) = attachment_ref.resolved_absolute_path.as_deref() {
|
||||||
|
let resolved_path = Path::new(path);
|
||||||
|
attachment_ref.authorized = Some(resolved_path.starts_with(&canonical_root));
|
||||||
|
}
|
||||||
|
attachment_ref
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
|
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
|
||||||
let page_subtree = markdown_page_subtree(document_id, &title, &content);
|
let page_subtree = markdown_page_subtree(document_id, &title, &content);
|
||||||
let workspace_id = local_workspace_id(&canonical_root);
|
let workspace_id = local_workspace_id(&canonical_root);
|
||||||
@@ -3063,6 +3078,7 @@ pub fn resolve_local_markdown_page_aggregate(
|
|||||||
block_document,
|
block_document,
|
||||||
block_projection_version: 1,
|
block_projection_version: 1,
|
||||||
projection_source: "local_markdown.content".into(),
|
projection_source: "local_markdown.content".into(),
|
||||||
|
attachment_refs: serde_json::to_value(attachment_refs).unwrap_or_else(|_| json!([])),
|
||||||
},
|
},
|
||||||
tree: PageTree { page_subtree },
|
tree: PageTree { page_subtree },
|
||||||
stats: PageStats {
|
stats: PageStats {
|
||||||
@@ -3927,14 +3943,7 @@ pub(crate) fn write_local_markdown_asset(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let page_resource_dir =
|
let asset_dir = markdown_dir.to_path_buf();
|
||||||
markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| {
|
|
||||||
WebError::bad_request_code(
|
|
||||||
"local_asset_upload_bad_markdown_path",
|
|
||||||
"无法解析本地页面资源目录",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let asset_dir = page_resource_dir;
|
|
||||||
if !asset_dir.starts_with(&canonical_root) {
|
if !asset_dir.starts_with(&canonical_root) {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"local_folder_root_escape",
|
"local_folder_root_escape",
|
||||||
@@ -3965,6 +3974,24 @@ pub(crate) fn write_local_markdown_asset(
|
|||||||
|
|
||||||
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
|
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
|
||||||
let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?;
|
let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?;
|
||||||
|
let markdown_href = markdown_href_for_relative_path(&markdown_relative_path);
|
||||||
|
let mut attachment_ref = parse_markdown_attachment_refs(
|
||||||
|
&format!(
|
||||||
|
"[{}]({})",
|
||||||
|
target
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or(&sanitized_name),
|
||||||
|
markdown_href
|
||||||
|
),
|
||||||
|
&markdown_file.path.display().to_string(),
|
||||||
|
&file_uri_for_path(&canonical_root),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.next();
|
||||||
|
if let Some(ref mut value) = attachment_ref {
|
||||||
|
value.authorized = Some(true);
|
||||||
|
}
|
||||||
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
||||||
let mut uploaded_assets = metadata.uploaded_assets;
|
let mut uploaded_assets = metadata.uploaded_assets;
|
||||||
uploaded_assets.insert(
|
uploaded_assets.insert(
|
||||||
@@ -3997,6 +4024,8 @@ pub(crate) fn write_local_markdown_asset(
|
|||||||
"rootUri": file_uri_for_path(&canonical_root),
|
"rootUri": file_uri_for_path(&canonical_root),
|
||||||
"rootRelativePath": root_relative_path,
|
"rootRelativePath": root_relative_path,
|
||||||
"markdownRelativePath": markdown_relative_path,
|
"markdownRelativePath": markdown_relative_path,
|
||||||
|
"markdownHref": markdown_href,
|
||||||
|
"attachmentRef": attachment_ref,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7063,7 +7092,11 @@ fn parent_key_for_relative_path(relative_path: &str) -> String {
|
|||||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("/");
|
.join("/");
|
||||||
if value.is_empty() { None } else { Some(value) }
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.map(|value| normalize_file_order_parent_key(&value))
|
.map(|value| normalize_file_order_parent_key(&value))
|
||||||
.unwrap_or_else(|| ".".to_string())
|
.unwrap_or_else(|| ".".to_string())
|
||||||
@@ -8130,6 +8163,15 @@ fn normalize_markdown_relative_asset_path(
|
|||||||
.join("/"))
|
.join("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn markdown_href_for_relative_path(relative_path: &str) -> String {
|
||||||
|
let trimmed = relative_path.trim().replace('\\', "/");
|
||||||
|
if trimmed.starts_with("./") || trimmed.starts_with("../") {
|
||||||
|
trimmed
|
||||||
|
} else {
|
||||||
|
format!("./{trimmed}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str {
|
fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str {
|
||||||
let normalized_kind = kind.trim().to_ascii_lowercase();
|
let normalized_kind = kind.trim().to_ascii_lowercase();
|
||||||
if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") {
|
if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") {
|
||||||
@@ -8384,6 +8426,7 @@ fn editor_blocks_to_markdown_with_rewrite(
|
|||||||
lines.push(text);
|
lines.push(text);
|
||||||
} else {
|
} else {
|
||||||
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
|
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
|
||||||
|
.map(|value| markdown_href_for_relative_path(&value))
|
||||||
.unwrap_or_else(|| src.to_string());
|
.unwrap_or_else(|| src.to_string());
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"",
|
"",
|
||||||
@@ -8412,9 +8455,7 @@ fn editor_blocks_to_markdown_with_rewrite(
|
|||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
lines.push(text);
|
lines.push(text);
|
||||||
} else {
|
} else {
|
||||||
let url = rewrite_local_open_url_to_markdown_relative(url, local_file_context)
|
let label = if name.is_empty() { url } else { name };
|
||||||
.unwrap_or_else(|| url.to_string());
|
|
||||||
let label = if name.is_empty() { url.as_str() } else { name };
|
|
||||||
lines.push(format!("[{}]({})", label, markdown_link_target(&url)));
|
lines.push(format!("[{}]({})", label, markdown_link_target(&url)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9041,7 +9082,7 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
|
|||||||
fn markdown_text_with_styles(
|
fn markdown_text_with_styles(
|
||||||
text: &str,
|
text: &str,
|
||||||
styles: &Value,
|
styles: &Value,
|
||||||
local_file_context: Option<(&Path, &Path)>,
|
_local_file_context: Option<(&Path, &Path)>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut value = escape_markdown_inline_text(text);
|
let mut value = escape_markdown_inline_text(text);
|
||||||
let link = styles
|
let link = styles
|
||||||
@@ -9078,8 +9119,6 @@ fn markdown_text_with_styles(
|
|||||||
value = format!("~~{value}~~");
|
value = format!("~~{value}~~");
|
||||||
}
|
}
|
||||||
if let Some(href) = link {
|
if let Some(href) = link {
|
||||||
let href =
|
|
||||||
rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href);
|
|
||||||
value = format!("[{}]({href})", value.replace(']', r"\]"));
|
value = format!("[{}]({href})", value.replace(']', r"\]"));
|
||||||
}
|
}
|
||||||
value
|
value
|
||||||
@@ -9224,10 +9263,6 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
|
||||||
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
|
|
||||||
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
|
|
||||||
SyncConflictReportRequest, SyncPendingChangeRequest,
|
|
||||||
add_sqlite_local_access_grant_for_context,
|
add_sqlite_local_access_grant_for_context,
|
||||||
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
|
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
|
||||||
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
|
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
|
||||||
@@ -9246,15 +9281,18 @@ mod tests {
|
|||||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||||
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
||||||
write_sync_conflict_report,
|
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||||
|
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
|
||||||
|
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
|
||||||
|
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||||
};
|
};
|
||||||
use crate::app::{AppConfig, AppState};
|
use crate::app::{AppConfig, AppState};
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Path as AxumPath, Query, State};
|
use axum::extract::{Extension, Path as AxumPath, Query, State};
|
||||||
use axum::http::{HeaderMap, Method, StatusCode};
|
use axum::http::{HeaderMap, Method, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
@@ -9526,25 +9564,28 @@ mod tests {
|
|||||||
"tableCell"
|
"tableCell"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
|
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]
|
||||||
|
["text"],
|
||||||
"左"
|
"左"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["text"],
|
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
|
||||||
|
["text"],
|
||||||
"A"
|
"A"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["marks"]
|
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
|
||||||
[0]["type"],
|
["marks"][0]["type"],
|
||||||
"code"
|
"code"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["text"],
|
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
|
||||||
|
["text"],
|
||||||
"B"
|
"B"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["marks"]
|
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
|
||||||
[0]["type"],
|
["marks"][0]["type"],
|
||||||
"bold"
|
"bold"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9647,12 +9688,10 @@ mod tests {
|
|||||||
let body = serde_json::to_value(&aggregate.body).expect("body json");
|
let body = serde_json::to_value(&aggregate.body).expect("body json");
|
||||||
|
|
||||||
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
|
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
|
||||||
assert!(
|
assert!(body["fileVersion"]
|
||||||
body["fileVersion"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("file version")
|
.expect("file version")
|
||||||
.starts_with("local-md:local-md:README.md:")
|
.starts_with("local-md:local-md:README.md:"));
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -9674,12 +9713,10 @@ mod tests {
|
|||||||
.expect("save");
|
.expect("save");
|
||||||
|
|
||||||
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
||||||
assert!(
|
assert!(result["fileVersion"]
|
||||||
result["fileVersion"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("file version")
|
.expect("file version")
|
||||||
.starts_with("local-md:local-md:README.md:")
|
.starts_with("local-md:local-md:README.md:"));
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -9842,7 +9879,7 @@ fn main() {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() {
|
fn local_markdown_save_does_not_migrate_runtime_open_url_inline_link() {
|
||||||
let root = temp_root("mnote-local-uploaded-md-inline-link");
|
let root = temp_root("mnote-local-uploaded-md-inline-link");
|
||||||
init_workspace(&root);
|
init_workspace(&root);
|
||||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||||
@@ -9888,28 +9925,18 @@ fn main() {}
|
|||||||
)
|
)
|
||||||
.expect("save inline attachment link");
|
.expect("save inline attachment link");
|
||||||
|
|
||||||
assert_eq!(asset["sourcePath"], "notes.md");
|
|
||||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||||
assert!(saved.contains("[notes.md](notes.md)"));
|
assert_eq!(asset["sourcePath"], "notes.md");
|
||||||
assert!(!saved.contains("/api/local-folder/files/open"));
|
assert!(
|
||||||
|
saved.contains("/api/local-folder/files/open"),
|
||||||
let aggregate =
|
"开发态不再把旧 runtime open URL 自动迁移为标准 href"
|
||||||
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
|
);
|
||||||
let media = aggregate
|
|
||||||
.body
|
|
||||||
.content
|
|
||||||
.as_array()
|
|
||||||
.expect("blocks")
|
|
||||||
.iter()
|
|
||||||
.find(|block| block["type"].as_str() == Some("media"))
|
|
||||||
.expect("uploaded md inline link should reload as media block");
|
|
||||||
assert_eq!(media["props"]["sourcePath"], "notes.md");
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() {
|
fn local_markdown_save_does_not_migrate_relative_runtime_open_url() {
|
||||||
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
|
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
|
||||||
init_workspace(&root);
|
init_workspace(&root);
|
||||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||||
@@ -9956,7 +9983,10 @@ fn main() {}
|
|||||||
.expect("save relative open url");
|
.expect("save relative open url");
|
||||||
|
|
||||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||||
assert!(saved.contains("[notes.md](notes.md)"));
|
assert!(
|
||||||
|
saved.contains("/api/local-folder/files/open"),
|
||||||
|
"开发态不再把旧 runtime open URL 自动迁移为标准 href"
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -10800,12 +10830,10 @@ fn main() {}
|
|||||||
assert_eq!(created["grant"]["ownerUserId"], "user_owner");
|
assert_eq!(created["grant"]["ownerUserId"], "user_owner");
|
||||||
assert_eq!(created["grant"]["targetUserId"], "user_target");
|
assert_eq!(created["grant"]["targetUserId"], "user_target");
|
||||||
assert_eq!(created["grant"]["permission"], "write");
|
assert_eq!(created["grant"]["permission"], "write");
|
||||||
assert!(
|
assert!(created["grant"]["shareId"]
|
||||||
created["grant"]["shareId"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.starts_with("folder_")
|
.starts_with("folder_"));
|
||||||
);
|
|
||||||
|
|
||||||
let outside_error = create_user_share_grant(
|
let outside_error = create_user_share_grant(
|
||||||
Extension(request_context("user_owner", "user")),
|
Extension(request_context("user_owner", "user")),
|
||||||
@@ -11075,13 +11103,11 @@ fn main() {}
|
|||||||
assert_eq!(stored.len(), 1);
|
assert_eq!(stored.len(), 1);
|
||||||
assert_eq!(stored[0].id, link_id);
|
assert_eq!(stored[0].id, link_id);
|
||||||
assert_ne!(stored[0].token_hash, "visible-token");
|
assert_ne!(stored[0].token_hash, "visible-token");
|
||||||
assert!(
|
assert!(state
|
||||||
state
|
|
||||||
.control_plane()
|
.control_plane()
|
||||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||||
.expect("resolve share link")
|
.expect("resolve share link")
|
||||||
.is_some()
|
.is_some());
|
||||||
);
|
|
||||||
|
|
||||||
let (_, Json(listed)) = get_share_links(
|
let (_, Json(listed)) = get_share_links(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
@@ -11114,13 +11140,11 @@ fn main() {}
|
|||||||
.expect("share revoked broadcast delta");
|
.expect("share revoked broadcast delta");
|
||||||
assert_eq!(revoked_delta["kind"], "control_plane_event");
|
assert_eq!(revoked_delta["kind"], "control_plane_event");
|
||||||
assert_eq!(revoked_delta["eventType"], "control.share.revoked");
|
assert_eq!(revoked_delta["eventType"], "control.share.revoked");
|
||||||
assert!(
|
assert!(state
|
||||||
state
|
|
||||||
.control_plane()
|
.control_plane()
|
||||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||||
.expect("resolve revoked share link")
|
.expect("resolve revoked share link")
|
||||||
.is_none()
|
.is_none());
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state
|
state
|
||||||
.control_plane()
|
.control_plane()
|
||||||
@@ -11324,13 +11348,11 @@ fn main() {}
|
|||||||
payload["workspace"]["manifest"]["ownerId"],
|
payload["workspace"]["manifest"]["ownerId"],
|
||||||
"user@example.com"
|
"user@example.com"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(payload["workspace"]["manifest"]["capabilities"]
|
||||||
payload["workspace"]["manifest"]["capabilities"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("capabilities")
|
.expect("capabilities")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.as_str() == Some("markdown_edit"))
|
.any(|value| value.as_str() == Some("markdown_edit")));
|
||||||
);
|
|
||||||
ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
|
ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
|
||||||
.expect("owner can access managed workspace");
|
.expect("owner can access managed workspace");
|
||||||
|
|
||||||
@@ -11581,12 +11603,11 @@ fn main() {}
|
|||||||
execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
|
execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
|
||||||
.expect("delete loose markdown");
|
.expect("delete loose markdown");
|
||||||
assert!(!root.join("Renamed.md").exists());
|
assert!(!root.join("Renamed.md").exists());
|
||||||
assert!(
|
assert!(root
|
||||||
root.join(".mnote")
|
.join(".mnote")
|
||||||
.join("trash")
|
.join("trash")
|
||||||
.join("Renamed.md")
|
.join("Renamed.md")
|
||||||
.is_file()
|
.is_file());
|
||||||
);
|
|
||||||
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
|
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
|
||||||
|
|
||||||
let restored =
|
let restored =
|
||||||
@@ -13098,6 +13119,11 @@ fn main() {}
|
|||||||
assert_eq!(asset["uploadIntent"], "editor.markdown.attach");
|
assert_eq!(asset["uploadIntent"], "editor.markdown.attach");
|
||||||
assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png");
|
assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png");
|
||||||
assert_eq!(asset["markdownRelativePath"], "photo-1.png");
|
assert_eq!(asset["markdownRelativePath"], "photo-1.png");
|
||||||
|
assert_eq!(asset["markdownHref"], "./photo-1.png");
|
||||||
|
assert_eq!(asset["attachmentRef"]["rawHref"], "./photo-1.png");
|
||||||
|
assert_eq!(asset["attachmentRef"]["kind"], "pageLocal");
|
||||||
|
assert_eq!(asset["attachmentRef"]["openKind"], "image");
|
||||||
|
assert_eq!(asset["attachmentRef"]["authorized"], true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
asset["ownerDocumentId"],
|
asset["ownerDocumentId"],
|
||||||
"local-md:docs~2FREADME~2FREADME.md"
|
"local-md:docs~2FREADME~2FREADME.md"
|
||||||
@@ -13122,6 +13148,8 @@ fn main() {}
|
|||||||
assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach");
|
assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach");
|
||||||
assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md");
|
assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md");
|
||||||
assert_eq!(markdown_asset["markdownRelativePath"], "notes.md");
|
assert_eq!(markdown_asset["markdownRelativePath"], "notes.md");
|
||||||
|
assert_eq!(markdown_asset["markdownHref"], "./notes.md");
|
||||||
|
assert_eq!(markdown_asset["attachmentRef"]["openKind"], "text");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
markdown_asset["ownerDocumentId"],
|
markdown_asset["ownerDocumentId"],
|
||||||
"local-md:docs~2FREADME~2FREADME.md"
|
"local-md:docs~2FREADME~2FREADME.md"
|
||||||
@@ -13155,6 +13183,31 @@ fn main() {}
|
|||||||
.expect("uploaded asset index");
|
.expect("uploaded asset index");
|
||||||
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
|
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
|
||||||
|
|
||||||
|
std::fs::write(root.join("docs").join("Loose.md"), "# Loose\n").expect("write loose md");
|
||||||
|
let loose_asset = write_local_markdown_asset(
|
||||||
|
&root_uri,
|
||||||
|
"local-md:docs~2FLoose.md",
|
||||||
|
"attachment",
|
||||||
|
LocalUploadFile {
|
||||||
|
name: "loose.pdf".to_string(),
|
||||||
|
content_type: "application/pdf".to_string(),
|
||||||
|
bytes: b"%PDF-1.4\n".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("upload loose markdown asset");
|
||||||
|
assert_eq!(loose_asset["sourcePath"], "loose.pdf");
|
||||||
|
assert_eq!(loose_asset["rootRelativePath"], "docs/loose.pdf");
|
||||||
|
assert_eq!(loose_asset["markdownRelativePath"], "loose.pdf");
|
||||||
|
assert_eq!(loose_asset["markdownHref"], "./loose.pdf");
|
||||||
|
assert!(
|
||||||
|
root.join("docs").join("loose.pdf").is_file(),
|
||||||
|
"非 bundle Markdown 上传应写入 md 同目录"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!root.join("docs").join("Loose").join("loose.pdf").exists(),
|
||||||
|
"非 bundle Markdown 上传不应写入同名子目录"
|
||||||
|
);
|
||||||
|
|
||||||
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
|
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
|
||||||
.expect("file tree");
|
.expect("file tree");
|
||||||
let items = snapshot.projection["items"].as_array().expect("items");
|
let items = snapshot.projection["items"].as_array().expect("items");
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
||||||
use comrak::{Arena, Options, parse_document};
|
use comrak::{parse_document, Arena, Options};
|
||||||
use serde_json::{Map, Value, json};
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ParsedLocalMarkdownPage {
|
pub struct ParsedLocalMarkdownPage {
|
||||||
@@ -78,6 +81,34 @@ struct MarkdownInlineStyles {
|
|||||||
link: Option<String>,
|
link: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AttachmentSourceRange {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AttachmentRef {
|
||||||
|
pub ref_id: String,
|
||||||
|
pub owner_document_path: String,
|
||||||
|
pub owner_root_uri: String,
|
||||||
|
pub raw_href: String,
|
||||||
|
pub normalized_href: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub resolved_uri: Option<String>,
|
||||||
|
pub resolved_absolute_path: Option<String>,
|
||||||
|
pub relative_path: Option<String>,
|
||||||
|
pub ext: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub exists: Option<bool>,
|
||||||
|
pub authorized: Option<bool>,
|
||||||
|
pub open_kind: String,
|
||||||
|
pub source_range: Option<AttachmentSourceRange>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
|
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
|
||||||
let (_frontmatter, body) = split_frontmatter(markdown);
|
let (_frontmatter, body) = split_frontmatter(markdown);
|
||||||
let body_owned = body.to_string();
|
let body_owned = body.to_string();
|
||||||
@@ -101,6 +132,28 @@ pub fn markdown_to_blocks_with_attachment_paths(
|
|||||||
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
|
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_markdown_attachment_refs(
|
||||||
|
markdown: &str,
|
||||||
|
owner_document_path: &str,
|
||||||
|
owner_root_uri: &str,
|
||||||
|
) -> Vec<AttachmentRef> {
|
||||||
|
let arena = Arena::new();
|
||||||
|
let options = markdown_options();
|
||||||
|
let root = parse_document(&arena, markdown, &options);
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
collect_attachment_refs_from_ast(
|
||||||
|
root,
|
||||||
|
markdown,
|
||||||
|
owner_document_path,
|
||||||
|
owner_root_uri,
|
||||||
|
&mut cursor,
|
||||||
|
&mut refs,
|
||||||
|
);
|
||||||
|
collect_html_embed_attachment_refs(markdown, owner_document_path, owner_root_uri, &mut refs);
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
|
||||||
fn markdown_options() -> Options<'static> {
|
fn markdown_options() -> Options<'static> {
|
||||||
let mut options = Options::default();
|
let mut options = Options::default();
|
||||||
options.extension.table = true;
|
options.extension.table = true;
|
||||||
@@ -159,6 +212,390 @@ fn parse_markdown_attachment_link_with_paths(
|
|||||||
Some((name.to_string(), target.to_string()))
|
Some((name.to_string(), target.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_attachment_refs_from_ast<'a>(
|
||||||
|
node: &'a AstNode<'a>,
|
||||||
|
markdown: &str,
|
||||||
|
owner_document_path: &str,
|
||||||
|
owner_root_uri: &str,
|
||||||
|
cursor: &mut usize,
|
||||||
|
refs: &mut Vec<AttachmentRef>,
|
||||||
|
) {
|
||||||
|
match &node.data.borrow().value {
|
||||||
|
NodeValue::Link(link) => {
|
||||||
|
let raw_href = link.url.trim();
|
||||||
|
if should_collect_attachment_href(raw_href) {
|
||||||
|
let label = collect_plain_text(node);
|
||||||
|
let source_range = find_href_source_range(markdown, raw_href, cursor);
|
||||||
|
refs.push(build_attachment_ref(
|
||||||
|
owner_document_path,
|
||||||
|
owner_root_uri,
|
||||||
|
raw_href,
|
||||||
|
label.trim(),
|
||||||
|
source_range,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeValue::Image(link) => {
|
||||||
|
let raw_href = link.url.trim();
|
||||||
|
if should_collect_attachment_href(raw_href) {
|
||||||
|
let label = collect_plain_text(node);
|
||||||
|
let source_range = find_href_source_range(markdown, raw_href, cursor);
|
||||||
|
refs.push(build_attachment_ref(
|
||||||
|
owner_document_path,
|
||||||
|
owner_root_uri,
|
||||||
|
raw_href,
|
||||||
|
label.trim(),
|
||||||
|
source_range,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
for child in node.children() {
|
||||||
|
collect_attachment_refs_from_ast(
|
||||||
|
child,
|
||||||
|
markdown,
|
||||||
|
owner_document_path,
|
||||||
|
owner_root_uri,
|
||||||
|
cursor,
|
||||||
|
refs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_html_embed_attachment_refs(
|
||||||
|
markdown: &str,
|
||||||
|
owner_document_path: &str,
|
||||||
|
owner_root_uri: &str,
|
||||||
|
refs: &mut Vec<AttachmentRef>,
|
||||||
|
) {
|
||||||
|
let lower = markdown.to_ascii_lowercase();
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while let Some(relative_start) = lower[offset..].find("<embed") {
|
||||||
|
let start = offset + relative_start;
|
||||||
|
let Some(relative_end) = lower[start..].find('>') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let end = start + relative_end + 1;
|
||||||
|
let fragment = &markdown[start..end];
|
||||||
|
if let Some((raw_href, value_start, value_end)) =
|
||||||
|
extract_html_attr(fragment, "src").or_else(|| extract_html_attr(fragment, "href"))
|
||||||
|
{
|
||||||
|
let source_range = Some(AttachmentSourceRange {
|
||||||
|
start: start + value_start,
|
||||||
|
end: start + value_end,
|
||||||
|
});
|
||||||
|
refs.push(build_attachment_ref(
|
||||||
|
owner_document_path,
|
||||||
|
owner_root_uri,
|
||||||
|
raw_href,
|
||||||
|
raw_href,
|
||||||
|
source_range,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usize, usize)> {
|
||||||
|
let lower = fragment.to_ascii_lowercase();
|
||||||
|
let needle = format!("{name}=");
|
||||||
|
let attr_start = lower.find(&needle)? + needle.len();
|
||||||
|
let bytes = fragment.as_bytes();
|
||||||
|
let quote = *bytes.get(attr_start)?;
|
||||||
|
if quote != b'"' && quote != b'\'' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value_start = attr_start + 1;
|
||||||
|
let value_end = fragment[value_start..]
|
||||||
|
.find(quote as char)
|
||||||
|
.map(|index| value_start + index)?;
|
||||||
|
Some((&fragment[value_start..value_end], value_start, value_end))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_collect_attachment_href(raw_href: &str) -> bool {
|
||||||
|
let trimmed = raw_href.trim();
|
||||||
|
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_attachment_ref(
|
||||||
|
owner_document_path: &str,
|
||||||
|
owner_root_uri: &str,
|
||||||
|
raw_href: &str,
|
||||||
|
label: &str,
|
||||||
|
source_range: Option<AttachmentSourceRange>,
|
||||||
|
) -> AttachmentRef {
|
||||||
|
let raw_href = raw_href.trim();
|
||||||
|
let normalized_href = normalize_attachment_href(raw_href);
|
||||||
|
let owner_document = Path::new(owner_document_path);
|
||||||
|
let owner_dir = owner_document.parent();
|
||||||
|
let (kind, resolved_absolute_path, relative_path) =
|
||||||
|
resolve_attachment_path(&normalized_href, owner_dir, owner_root_uri);
|
||||||
|
let resolved_uri = resolved_absolute_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| file_uri_for_attachment_path(Path::new(path)))
|
||||||
|
.or_else(|| {
|
||||||
|
if is_remote_href(&normalized_href) {
|
||||||
|
Some(normalized_href.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ext = attachment_extension(&normalized_href, resolved_absolute_path.as_deref());
|
||||||
|
let content_type = ext
|
||||||
|
.as_deref()
|
||||||
|
.and_then(attachment_content_type)
|
||||||
|
.map(str::to_string);
|
||||||
|
let exists = resolved_absolute_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| Path::new(path).exists());
|
||||||
|
let open_kind = attachment_open_kind(ext.as_deref()).to_string();
|
||||||
|
let label = if label.trim().is_empty() {
|
||||||
|
resolved_absolute_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| Path::new(path).file_name())
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.or_else(|| {
|
||||||
|
Path::new(&normalized_href)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
})
|
||||||
|
.unwrap_or(raw_href)
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
label.trim().to_string()
|
||||||
|
};
|
||||||
|
AttachmentRef {
|
||||||
|
ref_id: attachment_ref_id(owner_document_path, raw_href, &source_range),
|
||||||
|
owner_document_path: owner_document_path.to_string(),
|
||||||
|
owner_root_uri: owner_root_uri.to_string(),
|
||||||
|
raw_href: raw_href.to_string(),
|
||||||
|
normalized_href,
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
resolved_uri,
|
||||||
|
resolved_absolute_path,
|
||||||
|
relative_path,
|
||||||
|
ext,
|
||||||
|
content_type,
|
||||||
|
exists,
|
||||||
|
authorized: None,
|
||||||
|
open_kind,
|
||||||
|
source_range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_attachment_path(
|
||||||
|
href: &str,
|
||||||
|
owner_dir: Option<&Path>,
|
||||||
|
owner_root_uri: &str,
|
||||||
|
) -> (String, Option<String>, Option<String>) {
|
||||||
|
if is_remote_href(href) {
|
||||||
|
return ("remoteUrl".to_string(), None, None);
|
||||||
|
}
|
||||||
|
if let Some(path) = href.strip_prefix("file://").and_then(file_uri_path) {
|
||||||
|
return (
|
||||||
|
"externalFile".to_string(),
|
||||||
|
Some(normalize_path_string(&path)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let href_path = Path::new(href);
|
||||||
|
if href_path.is_absolute() {
|
||||||
|
return (
|
||||||
|
"externalFile".to_string(),
|
||||||
|
Some(normalize_path_string(href_path)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let decoded_href = percent_decode_lossy(href);
|
||||||
|
if decoded_href.starts_with("../") || decoded_href.contains("/../") {
|
||||||
|
return ("unknown".to_string(), None, None);
|
||||||
|
}
|
||||||
|
let relative_path = decoded_href
|
||||||
|
.strip_prefix("./")
|
||||||
|
.unwrap_or(&decoded_href)
|
||||||
|
.replace('\\', "/");
|
||||||
|
let resolved = owner_dir.map(|dir| normalize_path_string(&dir.join(&decoded_href)));
|
||||||
|
let root_path = owner_root_path(owner_root_uri);
|
||||||
|
let relative_to_root = resolved
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|path| {
|
||||||
|
root_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|root| relative_to_root_path(path, root))
|
||||||
|
})
|
||||||
|
.or(Some(relative_path));
|
||||||
|
("pageLocal".to_string(), resolved, relative_to_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_attachment_href(raw_href: &str) -> String {
|
||||||
|
let trimmed = raw_href
|
||||||
|
.trim()
|
||||||
|
.strip_prefix('<')
|
||||||
|
.and_then(|value| value.strip_suffix('>'))
|
||||||
|
.unwrap_or(raw_href.trim())
|
||||||
|
.trim();
|
||||||
|
if Path::new(trimmed).is_absolute() {
|
||||||
|
file_uri_for_attachment_path(Path::new(trimmed))
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_uri_path(value: &str) -> Option<PathBuf> {
|
||||||
|
let path = if let Some(rest) = value.strip_prefix("localhost/") {
|
||||||
|
format!("/{rest}")
|
||||||
|
} else if value.starts_with('/') {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/{value}")
|
||||||
|
};
|
||||||
|
Some(PathBuf::from(percent_decode_lossy(&path)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_root_path(root_uri: &str) -> Option<PathBuf> {
|
||||||
|
root_uri
|
||||||
|
.strip_prefix("file://")
|
||||||
|
.and_then(file_uri_path)
|
||||||
|
.or_else(|| {
|
||||||
|
let path = Path::new(root_uri);
|
||||||
|
if path.is_absolute() {
|
||||||
|
Some(path.to_path_buf())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_to_root_path(path: &str, root: &Path) -> Option<String> {
|
||||||
|
Path::new(path)
|
||||||
|
.strip_prefix(root)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.to_string_lossy().replace('\\', "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_href_source_range(
|
||||||
|
markdown: &str,
|
||||||
|
raw_href: &str,
|
||||||
|
cursor: &mut usize,
|
||||||
|
) -> Option<AttachmentSourceRange> {
|
||||||
|
let raw_href = raw_href.trim();
|
||||||
|
let start = markdown
|
||||||
|
.get(*cursor..)
|
||||||
|
.and_then(|tail| tail.find(raw_href).map(|index| *cursor + index))
|
||||||
|
.or_else(|| markdown.find(raw_href))?;
|
||||||
|
let end = start + raw_href.len();
|
||||||
|
*cursor = end;
|
||||||
|
Some(AttachmentSourceRange { start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_remote_href(href: &str) -> bool {
|
||||||
|
href.starts_with("http://") || href.starts_with("https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_extension(href: &str, path: Option<&str>) -> Option<String> {
|
||||||
|
path.or(Some(href))
|
||||||
|
.and_then(|value| Path::new(value).extension())
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.map(|value| value.trim_start_matches('.').to_ascii_lowercase())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_content_type(ext: &str) -> Option<&'static str> {
|
||||||
|
match ext {
|
||||||
|
"png" => Some("image/png"),
|
||||||
|
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||||
|
"gif" => Some("image/gif"),
|
||||||
|
"webp" => Some("image/webp"),
|
||||||
|
"svg" => Some("image/svg+xml"),
|
||||||
|
"pdf" => Some("application/pdf"),
|
||||||
|
"docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||||
|
"pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
|
||||||
|
"xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||||
|
"mp3" => Some("audio/mpeg"),
|
||||||
|
"wav" => Some("audio/wav"),
|
||||||
|
"mp4" => Some("video/mp4"),
|
||||||
|
"webm" => Some("video/webm"),
|
||||||
|
"md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml"
|
||||||
|
| "yaml" | "yml" => Some("text/plain"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_open_kind(ext: Option<&str>) -> &'static str {
|
||||||
|
match ext.unwrap_or_default() {
|
||||||
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "image",
|
||||||
|
"pdf" => "pdf",
|
||||||
|
"doc" | "docx" | "ppt" | "pptx" | "xls" | "xlsx" => "office",
|
||||||
|
"mp3" | "wav" | "ogg" | "m4a" => "audio",
|
||||||
|
"mp4" | "webm" | "mov" | "mkv" => "video",
|
||||||
|
"md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml"
|
||||||
|
| "yaml" | "yml" => "text",
|
||||||
|
"" => "unknown",
|
||||||
|
_ => "download",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path_string(path: &Path) -> String {
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::CurDir => {}
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
_ => normalized.push(component.as_os_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized.to_string_lossy().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_uri_for_attachment_path(path: &Path) -> String {
|
||||||
|
format!("file://{}", normalize_path_string(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode_lossy(value: &str) -> String {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut output = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0usize;
|
||||||
|
while index < bytes.len() {
|
||||||
|
if bytes[index] == b'%' && index + 2 < bytes.len() {
|
||||||
|
if let (Some(high), Some(low)) =
|
||||||
|
(hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
|
||||||
|
{
|
||||||
|
output.push(high * 16 + low);
|
||||||
|
index += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push(bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&output).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_value(value: u8) -> Option<u8> {
|
||||||
|
match value {
|
||||||
|
b'0'..=b'9' => Some(value - b'0'),
|
||||||
|
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_ref_id(
|
||||||
|
owner_document_path: &str,
|
||||||
|
raw_href: &str,
|
||||||
|
source_range: &Option<AttachmentSourceRange>,
|
||||||
|
) -> String {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
owner_document_path.hash(&mut hasher);
|
||||||
|
raw_href.hash(&mut hasher);
|
||||||
|
source_range.hash(&mut hasher);
|
||||||
|
format!("attachment:{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_markdown_ast_document(
|
fn parse_markdown_ast_document(
|
||||||
markdown: &str,
|
markdown: &str,
|
||||||
attachment_paths: &BTreeSet<String>,
|
attachment_paths: &BTreeSet<String>,
|
||||||
@@ -787,7 +1224,7 @@ pub(crate) fn file_stem_title(file_name: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{markdown_to_blocks, parse_markdown_page};
|
use super::{markdown_to_blocks, parse_markdown_attachment_refs, parse_markdown_page};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn markdown_image_parses_as_image_block() {
|
fn markdown_image_parses_as_image_block() {
|
||||||
@@ -802,6 +1239,94 @@ mod tests {
|
|||||||
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
|
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_attachment_refs_parse_standard_href_variants() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-attachment-ref-parser-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let owner_dir = root.join("docs");
|
||||||
|
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
|
||||||
|
std::fs::write(owner_dir.join("同目录 文件.pdf"), b"pdf").expect("write relative file");
|
||||||
|
std::fs::write(owner_dir.join("figure.png"), b"png").expect("write image file");
|
||||||
|
let owner = owner_dir.join("page.md");
|
||||||
|
let external = root.join("external.docx");
|
||||||
|
std::fs::write(&external, b"docx").expect("write external file");
|
||||||
|
let markdown = format!(
|
||||||
|
"[同目录 PDF](./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf)\n\
|
||||||
|
\n\
|
||||||
|
[外部](file://{})\n\
|
||||||
|
[网页资料](https://example.com/paper.pdf)\n\
|
||||||
|
[裸绝对]({})\n\
|
||||||
|
<embed src=\"./missing.pptx\">\n",
|
||||||
|
external.display(),
|
||||||
|
external.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let refs = parse_markdown_attachment_refs(
|
||||||
|
&markdown,
|
||||||
|
&owner.display().to_string(),
|
||||||
|
&format!("file://{}", root.display()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(refs.len(), 6);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].raw_href,
|
||||||
|
"./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf"
|
||||||
|
);
|
||||||
|
assert_eq!(refs[0].kind, "pageLocal");
|
||||||
|
assert_eq!(refs[0].open_kind, "pdf");
|
||||||
|
assert_eq!(refs[0].exists, Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].relative_path,
|
||||||
|
Some("docs/同目录 文件.pdf".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(refs[1].open_kind, "image");
|
||||||
|
assert_eq!(refs[2].kind, "externalFile");
|
||||||
|
assert_eq!(refs[2].open_kind, "office");
|
||||||
|
assert_eq!(refs[3].kind, "remoteUrl");
|
||||||
|
assert_eq!(
|
||||||
|
refs[3].resolved_uri,
|
||||||
|
Some("https://example.com/paper.pdf".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
refs[4].normalized_href,
|
||||||
|
format!("file://{}", external.display())
|
||||||
|
);
|
||||||
|
assert_eq!(refs[5].raw_href, "./missing.pptx");
|
||||||
|
assert_eq!(refs[5].open_kind, "office");
|
||||||
|
assert_eq!(refs[5].exists, Some(false));
|
||||||
|
assert!(refs.iter().all(|item| item.source_range.is_some()));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_attachment_refs_do_not_resolve_parent_directory_relative_paths() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-attachment-ref-parent-relative-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let owner_dir = root.join("docs");
|
||||||
|
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
|
||||||
|
let owner = owner_dir.join("page.md");
|
||||||
|
|
||||||
|
let refs = parse_markdown_attachment_refs(
|
||||||
|
"[上级目录](../assets/a.pdf)\n",
|
||||||
|
&owner.display().to_string(),
|
||||||
|
&format!("file://{}", root.display()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].raw_href, "../assets/a.pdf");
|
||||||
|
assert_eq!(refs[0].kind, "unknown");
|
||||||
|
assert_eq!(refs[0].resolved_absolute_path, None);
|
||||||
|
assert_eq!(refs[0].relative_path, None);
|
||||||
|
assert_eq!(refs[0].exists, None);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
/// 固定空引用块行为:`>` 在 GFM AST 中产生 BlockQuote 节点,
|
/// 固定空引用块行为:`>` 在 GFM AST 中产生 BlockQuote 节点,
|
||||||
/// collect_inline_children 返回空 vec → 输出 type=quote content=[]。
|
/// collect_inline_children 返回空 vec → 输出 type=quote content=[]。
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::routes::local_markdown_parser::{
|
|||||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -852,55 +852,41 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||||
.expect("home result");
|
.expect("home result");
|
||||||
assert!(
|
assert!(home["tags"]
|
||||||
home["tags"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tag| tag.as_str() == Some("alpha"))
|
.any(|tag| tag.as_str() == Some("alpha")));
|
||||||
);
|
assert!(home["backlinks"]
|
||||||
assert!(
|
|
||||||
home["backlinks"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|link| link.as_str() == Some("Daily"))
|
.any(|link| link.as_str() == Some("Daily")));
|
||||||
);
|
assert!(home["backlinks"]
|
||||||
assert!(
|
|
||||||
home["backlinks"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|link| link.as_str() == Some("docs/child.md"))
|
.any(|link| link.as_str() == Some("docs/child.md")));
|
||||||
);
|
assert!(home["resourceRefs"]
|
||||||
assert!(
|
|
||||||
home["resourceRefs"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||||
);
|
assert!(home["resourceRefs"]
|
||||||
assert!(
|
|
||||||
home["resourceRefs"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
|
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
|
||||||
);
|
assert!(home["resourceRefs"]
|
||||||
assert!(
|
|
||||||
home["resourceRefs"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
|
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
|
||||||
);
|
assert!(home["publicPath"]
|
||||||
assert!(
|
|
||||||
home["publicPath"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.is_some_and(|path| path.starts_with(
|
.is_some_and(|path| path.starts_with(
|
||||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||||
))
|
)));
|
||||||
);
|
|
||||||
|
|
||||||
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
|
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
|
||||||
let child_search = query_local_search_index(
|
let child_search = query_local_search_index(
|
||||||
@@ -929,12 +915,11 @@ mod tests {
|
|||||||
Some("local-mdid:child-page")
|
Some("local-mdid:child-page")
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(root
|
||||||
root.join(".mnote")
|
.join(".mnote")
|
||||||
.join("index")
|
.join("index")
|
||||||
.join("search-index.json")
|
.join("search-index.json")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
let mindmap_projection = query_local_search_index(
|
let mindmap_projection = query_local_search_index(
|
||||||
&root,
|
&root,
|
||||||
&format!("file://{}", root.display()),
|
&format!("file://{}", root.display()),
|
||||||
@@ -946,19 +931,19 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.expect("mindmap projection");
|
.expect("mindmap projection");
|
||||||
assert!(
|
assert!(mindmap_projection["results"]
|
||||||
mindmap_projection["results"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||||
&& item["publicPath"].as_str().is_some_and(|path| path
|
&& item["publicPath"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|path| path
|
||||||
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
|
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
|
||||||
&& item["publicPath"]
|
&& item["publicPath"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.is_some_and(|path| !path.starts_with("/tree?")))
|
.is_some_and(|path| !path.starts_with("/tree?"))));
|
||||||
);
|
|
||||||
let office_projection = query_local_search_index(
|
let office_projection = query_local_search_index(
|
||||||
&root,
|
&root,
|
||||||
&format!("file://{}", root.display()),
|
&format!("file://{}", root.display()),
|
||||||
@@ -970,14 +955,12 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.expect("office projection");
|
.expect("office projection");
|
||||||
assert!(
|
assert!(office_projection["results"]
|
||||||
office_projection["results"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||||
&& item["path"].as_str() == Some("office/report.xlsx"))
|
&& item["path"].as_str() == Some("office/report.xlsx")));
|
||||||
);
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,12 +987,10 @@ mod tests {
|
|||||||
let index = read_local_search_index(&root)
|
let index = read_local_search_index(&root)
|
||||||
.expect("read index")
|
.expect("read index")
|
||||||
.expect("index exists");
|
.expect("index exists");
|
||||||
assert!(
|
assert!(index
|
||||||
index
|
|
||||||
.documents
|
.documents
|
||||||
.iter()
|
.iter()
|
||||||
.any(|document| document.path == "README.md")
|
.any(|document| document.path == "README.md"));
|
||||||
);
|
|
||||||
let child = index
|
let child = index
|
||||||
.documents
|
.documents
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1025,18 +1006,14 @@ mod tests {
|
|||||||
let index = read_local_search_index(&root)
|
let index = read_local_search_index(&root)
|
||||||
.expect("read index")
|
.expect("read index")
|
||||||
.expect("index exists");
|
.expect("index exists");
|
||||||
assert!(
|
assert!(!index
|
||||||
!index
|
|
||||||
.documents
|
.documents
|
||||||
.iter()
|
.iter()
|
||||||
.any(|document| document.path == "docs/child.md")
|
.any(|document| document.path == "docs/child.md"));
|
||||||
);
|
assert!(index
|
||||||
assert!(
|
|
||||||
index
|
|
||||||
.documents
|
.documents
|
||||||
.iter()
|
.iter()
|
||||||
.any(|document| document.path == "README.md")
|
.any(|document| document.path == "README.md"));
|
||||||
);
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ use crate::transport::convex::{
|
|||||||
persist_runtime_command_artifacts,
|
persist_runtime_command_artifacts,
|
||||||
};
|
};
|
||||||
use axum::extract::{Multipart, Query, State};
|
use axum::extract::{Multipart, Query, State};
|
||||||
use axum::http::{HeaderMap, header};
|
use axum::http::{header, HeaderMap};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{Extension, Json};
|
use axum::{Extension, Json};
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||||
build_runtime_command_artifact_plan,
|
RuntimeSourceWire, RuntimeTargetWire,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use time::OffsetDateTime;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ use crate::routes::query_support::{
|
|||||||
fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud,
|
fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud,
|
||||||
resolve_effective_workspace_id,
|
resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Path, Query, State};
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
|
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||||
RuntimeTargetWire, apply_mindmap_kernel_commands_to_value,
|
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||||
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
|
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
|
||||||
@@ -469,10 +469,10 @@ pub async fn apply_mindmap_command(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
|
|||||||
@@ -250,12 +250,19 @@ fn escape_script_json(value: &str) -> String {
|
|||||||
value.replace("</script", "<\\/script")
|
value.replace("</script", "<\\/script")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
fn render_mindmap_standalone_bootstrap_script() -> String {
|
||||||
r#"<script type="module">
|
r#"<script type="module">
|
||||||
(() => {
|
(() => {
|
||||||
|
const DEV_HOT_BUSTER = "__MNOTE_DEV_HOT_BUSTER__";
|
||||||
const BOOTSTRAP_ID = '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__';
|
const BOOTSTRAP_ID = '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__';
|
||||||
const MOUNT_ID = 'mnote-mindmap-island';
|
const MOUNT_ID = 'mnote-mindmap-island';
|
||||||
|
|
||||||
|
const withDevHot = (path) => {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
if (DEV_HOT_BUSTER) url.searchParams.set('devHot', DEV_HOT_BUSTER);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
const parseJsonScript = (id) => {
|
const parseJsonScript = (id) => {
|
||||||
const node = document.getElementById(id);
|
const node = document.getElementById(id);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
@@ -272,12 +279,12 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
|||||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||||
}
|
}
|
||||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
|
const manifestResponse = await fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'));
|
||||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||||
const manifest = await manifestResponse.json();
|
const manifest = await manifestResponse.json();
|
||||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||||
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
|
const entryUrl = withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`);
|
||||||
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
|
const wasmUrl = manifest.wasmAssetPath ? withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`) : undefined;
|
||||||
const runtime = await import(entryUrl);
|
const runtime = await import(entryUrl);
|
||||||
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
||||||
throw new Error('island runtime 导出不完整');
|
throw new Error('island runtime 导出不完整');
|
||||||
@@ -322,12 +329,16 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
})();
|
})();
|
||||||
</script>"#
|
</script>"#
|
||||||
|
.replace(
|
||||||
|
"__MNOTE_DEV_HOT_BUSTER__",
|
||||||
|
crate::routes::dev_hot::dev_hot_cache_buster().unwrap_or(""),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
@@ -493,6 +504,25 @@ mod tests {
|
|||||||
assert!(!html.contains("next-app-router"));
|
assert!(!html.contains("next-app-router"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() {
|
||||||
|
let _guard = crate::test_support::hermes_env_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("env lock");
|
||||||
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
|
let script = super::render_mindmap_standalone_bootstrap_script();
|
||||||
|
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
|
||||||
|
|
||||||
|
assert!(script.contains("const DEV_HOT_BUSTER = \""));
|
||||||
|
assert!(script.contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))"));
|
||||||
|
assert!(
|
||||||
|
script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() {
|
async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() {
|
||||||
let response = app_with_mindmap_fixture()
|
let response = app_with_mindmap_fixture()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
mod bridge;
|
mod bridge;
|
||||||
pub(crate) mod command_support;
|
pub(crate) mod command_support;
|
||||||
mod compat;
|
mod compat;
|
||||||
mod dev_hot;
|
pub(crate) mod dev_hot;
|
||||||
mod documents;
|
mod documents;
|
||||||
mod editor;
|
mod editor;
|
||||||
mod gateway;
|
mod gateway;
|
||||||
@@ -42,9 +42,9 @@ pub(crate) use local_folder_source::{
|
|||||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use axum::Router;
|
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::routing::{any, delete, get, post, put};
|
use axum::routing::{any, delete, get, post, put};
|
||||||
|
use axum::Router;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
let hermes_base_path = state.config().hermes_base_path.clone();
|
let hermes_base_path = state.config().hermes_base_path.clone();
|
||||||
@@ -572,10 +572,10 @@ mod tests {
|
|||||||
use super::build_router;
|
use super::build_router;
|
||||||
use crate::app::{AppConfig, AppState};
|
use crate::app::{AppConfig, AppState};
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::Router;
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::body::{Body, to_bytes};
|
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use axum::Router;
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
@@ -977,7 +977,8 @@ mod tests {
|
|||||||
"reasonix"
|
"reasonix"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]["folder"],
|
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
|
||||||
|
["folder"],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1000,12 +1001,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("bob body");
|
.expect("bob body");
|
||||||
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json");
|
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json");
|
||||||
assert!(
|
assert!(bob_payload["result"]["aiPreferences"]
|
||||||
bob_payload["result"]["aiPreferences"]
|
|
||||||
.as_object()
|
.as_object()
|
||||||
.map(|value| value.is_empty())
|
.map(|value| value.is_empty())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false));
|
||||||
);
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ use crate::context::RequestContext;
|
|||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::gateway::current_actor_id;
|
use crate::routes::gateway::current_actor_id;
|
||||||
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
|
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
|
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
@@ -353,9 +353,9 @@ impl EmptyStringExt for String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode, header};
|
use axum::http::{header, Request, StatusCode};
|
||||||
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,29 @@ use crate::app::AppConfig;
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use adapter_onlyoffice::{
|
use adapter_onlyoffice::{
|
||||||
OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, prepare_callback,
|
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
|
||||||
prepare_proxy_request, sign_config,
|
OnlyOfficeProxyPreparationInput,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header};
|
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use hyper::upgrade::Upgraded;
|
use hyper::upgrade::Upgraded;
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path as FsPath, PathBuf};
|
use std::path::{Path as FsPath, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio_tungstenite::WebSocketStream;
|
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
|
||||||
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
||||||
use tokio_tungstenite::tungstenite::protocol::Role;
|
use tokio_tungstenite::tungstenite::protocol::Role;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
|
use tokio_tungstenite::WebSocketStream;
|
||||||
|
|
||||||
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
|
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
|
||||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
|
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
|
||||||
@@ -1705,10 +1705,9 @@ mod tests {
|
|||||||
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
||||||
let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
|
let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
|
||||||
assert!(key.len() <= 128);
|
assert!(key.len() <= 128);
|
||||||
assert!(
|
assert!(key
|
||||||
key.chars()
|
.chars()
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||||
);
|
|
||||||
assert!(key.starts_with("asset_1_"));
|
assert!(key.starts_with("asset_1_"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1722,10 +1721,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(key.len() <= 128);
|
assert!(key.len() <= 128);
|
||||||
assert!(key.starts_with("mnote_"));
|
assert!(key.starts_with("mnote_"));
|
||||||
assert!(
|
assert!(key
|
||||||
key.chars()
|
.chars()
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||||
);
|
|
||||||
assert!(!key.contains('/'));
|
assert!(!key.contains('/'));
|
||||||
assert!(!key.contains(':'));
|
assert!(!key.contains(':'));
|
||||||
assert!(!key.contains('重'));
|
assert!(!key.contains('重'));
|
||||||
@@ -2025,11 +2023,8 @@ mod tests {
|
|||||||
let request = captured.await.expect("captured");
|
let request = captured.await.expect("captured");
|
||||||
|
|
||||||
assert_eq!(payload["error"], 0);
|
assert_eq!(payload["error"], 0);
|
||||||
assert!(
|
assert!(request
|
||||||
request.starts_with(
|
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
|
||||||
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert!(request.contains(r#""status":2"#));
|
assert!(request.contains(r#""status":2"#));
|
||||||
assert!(request.contains(r#""key":"doc_key""#));
|
assert!(request.contains(r#""key":"doc_key""#));
|
||||||
}
|
}
|
||||||
@@ -2084,10 +2079,8 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(payload["ok"], true);
|
assert_eq!(payload["ok"], true);
|
||||||
assert_eq!(payload["via"], "forcesave");
|
assert_eq!(payload["via"], "forcesave");
|
||||||
assert!(
|
assert!(request
|
||||||
request
|
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
||||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::ToolCallInput;
|
use crate::hermes_tools::ToolCallInput;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -587,12 +587,12 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
@@ -952,18 +952,14 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
assert_eq!(payload["ok"], true);
|
assert_eq!(payload["ok"], true);
|
||||||
assert!(
|
assert!(payload["message"]
|
||||||
payload["message"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("已读取第一段:第一段")
|
.contains("已读取第一段:第一段"));
|
||||||
);
|
assert!(payload["message"]
|
||||||
assert!(
|
|
||||||
payload["message"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("测试123")
|
.contains("测试123"));
|
||||||
);
|
|
||||||
|
|
||||||
std::env::remove_var("HERMES_HOME");
|
std::env::remove_var("HERMES_HOME");
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&hermes_home);
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use crate::context::RequestContext;
|
|||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::transport::convex::execute_retired_query_plan;
|
use crate::transport::convex::execute_retired_query_plan;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
|
build_query_request, execute_runtime_input, execute_runtime_query, BridgeContext,
|
||||||
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, build_query_request,
|
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
|
||||||
execute_runtime_input, execute_runtime_query,
|
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire,
|
||||||
};
|
};
|
||||||
use core_protocol::{GetPageMeta, QueryEnvelope};
|
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ use crate::transport::convex::{
|
|||||||
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
||||||
persist_runtime_command_artifacts,
|
persist_runtime_command_artifacts,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Path, Query, State};
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire,
|
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||||
RuntimeTargetWire, build_runtime_command_artifact_plan,
|
RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
@@ -1148,8 +1148,8 @@ pub async fn table_empty_trash(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
@@ -1311,12 +1311,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||||
assert!(!root.join("Page").join("map.mindmap.json").exists());
|
assert!(!root.join("Page").join("map.mindmap.json").exists());
|
||||||
assert!(
|
assert!(root
|
||||||
root.join(".mnote")
|
.join(".mnote")
|
||||||
.join("trash")
|
.join("trash")
|
||||||
.join("map.mindmap.json")
|
.join("map.mindmap.json")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
|
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
|
||||||
original_markdown,
|
original_markdown,
|
||||||
@@ -1422,13 +1421,11 @@ mod tests {
|
|||||||
purged_payload["result"]["canonicalCommand"],
|
purged_payload["result"]["canonicalCommand"],
|
||||||
"tree.resource.purge"
|
"tree.resource.purge"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(!root
|
||||||
!root
|
|
||||||
.join(".mnote")
|
.join(".mnote")
|
||||||
.join("trash")
|
.join("trash")
|
||||||
.join("map.mindmap.json")
|
.join("map.mindmap.json")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ use crate::routes::query_support::{
|
|||||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||||
use crate::routes::{local_folder_source, local_search_index};
|
use crate::routes::{local_folder_source, local_search_index};
|
||||||
use crate::ssr::pages::search::SearchPage;
|
use crate::ssr::pages::search::SearchPage;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||||
const HEADER_QUERY_NAME: &str = "x-query-name";
|
const HEADER_QUERY_NAME: &str = "x-query-name";
|
||||||
@@ -546,10 +546,10 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
@@ -788,40 +788,31 @@ mod tests {
|
|||||||
.expect("home result");
|
.expect("home result");
|
||||||
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
||||||
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
||||||
assert!(
|
assert!(home["tags"]
|
||||||
home["tags"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tag| tag.as_str() == Some("alpha"))
|
.any(|tag| tag.as_str() == Some("alpha")));
|
||||||
);
|
assert!(home["backlinks"]
|
||||||
assert!(
|
|
||||||
home["backlinks"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|link| link.as_str() == Some("Daily"))
|
.any(|link| link.as_str() == Some("Daily")));
|
||||||
);
|
assert!(home["resourceRefs"]
|
||||||
assert!(
|
|
||||||
home["resourceRefs"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||||
);
|
assert!(root
|
||||||
assert!(
|
.join(".mnote")
|
||||||
root.join(".mnote")
|
|
||||||
.join("index")
|
.join("index")
|
||||||
.join("search-index.json")
|
.join("search-index.json")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
assert!(payload["recent"]
|
||||||
assert!(
|
|
||||||
payload["recent"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||||
);
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -930,13 +921,11 @@ mod tests {
|
|||||||
backlinks_payload["meta"]["queryName"].as_str(),
|
backlinks_payload["meta"]["queryName"].as_str(),
|
||||||
Some("search.local_index.backlinks")
|
Some("search.local_index.backlinks")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(backlinks_payload["result"]["backlinks"]
|
||||||
backlinks_payload["result"]["backlinks"]
|
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||||
);
|
|
||||||
|
|
||||||
let tags_response = app()
|
let tags_response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use axum::Json;
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use control_plane::session_token_hash;
|
use control_plane::session_token_hash;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -213,10 +213,10 @@ fn stamp_owner_header(headers: &mut HeaderMap) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
|
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
fn app() -> axum::Router {
|
fn app() -> axum::Router {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::gateway::current_actor_id;
|
use crate::routes::gateway::current_actor_id;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Path, Query, State};
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
|
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
|
|||||||
};
|
};
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProjectionSnapshotSpec<'a> {
|
pub struct ProjectionSnapshotSpec<'a> {
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::stream_support::{
|
use crate::routes::stream_support::{
|
||||||
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload,
|
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||||
build_stream_push_delta_hint, load_stream_overview, load_stream_snapshot,
|
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
StreamChangeKind, StreamSnapshotQuery,
|
||||||
};
|
};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
@@ -340,9 +340,9 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
|
|||||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use crate::routes::snapshot_support::{
|
use crate::routes::snapshot_support::{
|
||||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||||
subtree_query,
|
ProjectionSnapshotSpec,
|
||||||
};
|
};
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
use core_protocol::KernelProjectionKind;
|
use core_protocol::KernelProjectionKind;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
|
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
|
||||||
"page.body.save",
|
"page.body.save",
|
||||||
@@ -701,9 +701,8 @@ pub async fn load_stream_snapshot(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
|
||||||
delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
|
delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
|
||||||
resolve_stream_scope,
|
resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|||||||
@@ -6,42 +6,42 @@ use crate::routes::command_support::{
|
|||||||
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
|
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
|
||||||
};
|
};
|
||||||
use crate::routes::local_folder_source::{
|
use crate::routes::local_folder_source::{
|
||||||
LocalAccessMode, ensure_local_workspace_access_with_state,
|
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
|
||||||
ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
|
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
|
||||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
local_workspace_id_from_root_uri, LocalAccessMode,
|
||||||
};
|
};
|
||||||
use crate::routes::query_support::{
|
use crate::routes::query_support::{
|
||||||
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
|
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
|
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||||
use crate::transport::convex::execute_retired_mutation_by_name;
|
use crate::transport::convex::execute_retired_mutation_by_name;
|
||||||
use crate::tree_shell::filetree_renderer::{
|
use crate::tree_shell::filetree_renderer::{
|
||||||
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
|
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
|
||||||
};
|
};
|
||||||
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||||
use crate::tree_shell::page_renderer::{
|
use crate::tree_shell::page_renderer::{
|
||||||
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
|
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
|
||||||
};
|
};
|
||||||
use crate::tree_shell::picker_renderer::{
|
use crate::tree_shell::picker_renderer::{
|
||||||
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
|
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
|
||||||
};
|
};
|
||||||
use crate::tree_shell::renderer_input::{
|
use crate::tree_shell::renderer_input::{
|
||||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||||
TreeShellRendererInput,
|
TreeShellRendererInput,
|
||||||
};
|
};
|
||||||
use crate::tree_shell::runtime_api::{
|
use crate::tree_shell::runtime_api::{
|
||||||
TreeShellRuntimeRequest, TreeShellRuntimeResult,
|
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
|
||||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
|
TreeShellRuntimeResult,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderValue, StatusCode, header};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
||||||
use core_protocol::KernelProjectionKind;
|
use core_protocol::KernelProjectionKind;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -850,6 +850,18 @@ fn build_tree_shell_html(
|
|||||||
exclude_ids: &[String],
|
exclude_ids: &[String],
|
||||||
dataset: &Value,
|
dataset: &Value,
|
||||||
) -> String {
|
) -> String {
|
||||||
|
let source_kind = projection
|
||||||
|
.get("sourceKind")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("convex_workspace");
|
||||||
|
let root_uri = projection
|
||||||
|
.get("rootUri")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let watch_revision = projection
|
||||||
|
.get("watchRevision")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
let renderer_input = build_tree_shell_renderer_input(
|
let renderer_input = build_tree_shell_renderer_input(
|
||||||
projection,
|
projection,
|
||||||
mode,
|
mode,
|
||||||
@@ -869,15 +881,9 @@ fn build_tree_shell_html(
|
|||||||
"channel": channel,
|
"channel": channel,
|
||||||
"host": host,
|
"host": host,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"sourceKind": projection
|
"sourceKind": source_kind,
|
||||||
.get("sourceKind")
|
"rootUri": root_uri,
|
||||||
.and_then(Value::as_str)
|
"localWatchRevision": watch_revision.clone(),
|
||||||
.unwrap_or("convex_workspace"),
|
|
||||||
"rootUri": projection
|
|
||||||
.get("rootUri")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or(""),
|
|
||||||
"localWatchRevision": projection.get("watchRevision").cloned().unwrap_or(Value::Null),
|
|
||||||
"allowRootPick": allow_root_pick,
|
"allowRootPick": allow_root_pick,
|
||||||
"excludeIds": exclude_ids,
|
"excludeIds": exclude_ids,
|
||||||
"rendererInput": renderer_input,
|
"rendererInput": renderer_input,
|
||||||
@@ -892,6 +898,22 @@ fn build_tree_shell_html(
|
|||||||
});
|
});
|
||||||
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
|
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
|
||||||
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
|
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
|
||||||
|
let tree_live_bootstrap = serde_json::json!({
|
||||||
|
"schema": "mnote.tree_live_bootstrap.v1",
|
||||||
|
"disabled": false,
|
||||||
|
"transport": if source_kind == "local_folder" { "local-folder-events" } else { "tree-live-ws" },
|
||||||
|
"endpoint": "/api/tree/events",
|
||||||
|
"wsEndpoint": "/api/realtime/ws",
|
||||||
|
"workspaceId": workspace_id,
|
||||||
|
"rootIds": root_node_id
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| vec![value])
|
||||||
|
.unwrap_or_default(),
|
||||||
|
"initialRevision": watch_revision,
|
||||||
|
});
|
||||||
|
let tree_live_bootstrap_json =
|
||||||
|
serde_json::to_string(&tree_live_bootstrap).unwrap_or_else(|_| "{}".into());
|
||||||
let initial_tree_html = match mode {
|
let initial_tree_html = match mode {
|
||||||
"page" => render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
"page" => render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||||
rows: collect_page_tree_render_rows(projection),
|
rows: collect_page_tree_render_rows(projection),
|
||||||
@@ -1589,7 +1611,7 @@ fn build_tree_shell_html(
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-mnote-root-uri="__ROOT_URI__">
|
||||||
<main>
|
<main>
|
||||||
<section class="tree-card">
|
<section class="tree-card">
|
||||||
<div class="tree-card-header">
|
<div class="tree-card-header">
|
||||||
@@ -1606,7 +1628,9 @@ fn build_tree_shell_html(
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script id="tree-shell-state" type="application/json">__APP_STATE__</script>
|
<script id="tree-shell-state" type="application/json">__APP_STATE__</script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/tree-shell-runtime.js"></script>
|
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json">__TREE_LIVE_BOOTSTRAP__</script>
|
||||||
|
<script type="module" src="__TREE_SHELL_RUNTIME_SRC__"></script>
|
||||||
|
<script type="module" src="__TREE_LIVE_CONTROLLER_SRC__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"##;
|
"##;
|
||||||
@@ -1615,9 +1639,22 @@ fn build_tree_shell_html(
|
|||||||
.replace("__WORKSPACE_ID__", &escape_html(workspace_id))
|
.replace("__WORKSPACE_ID__", &escape_html(workspace_id))
|
||||||
.replace("__ROOT_LABEL__", &escape_html(root_label))
|
.replace("__ROOT_LABEL__", &escape_html(root_label))
|
||||||
.replace("__ACTIVE_LABEL__", &escape_html(active_label))
|
.replace("__ACTIVE_LABEL__", &escape_html(active_label))
|
||||||
|
.replace("__ROOT_URI__", &escape_html(root_uri))
|
||||||
.replace("__PROJECTION_JSON__", &escape_html(&projection_json))
|
.replace("__PROJECTION_JSON__", &escape_html(&projection_json))
|
||||||
.replace("__INITIAL_TREE_HTML__", &initial_tree_html)
|
.replace("__INITIAL_TREE_HTML__", &initial_tree_html)
|
||||||
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
|
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
|
||||||
|
.replace(
|
||||||
|
"__TREE_SHELL_RUNTIME_SRC__",
|
||||||
|
&crate::routes::web_shell::mnote_browser_runtime_src("tree-shell-runtime.js"),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"__TREE_LIVE_CONTROLLER_SRC__",
|
||||||
|
&crate::routes::web_shell::mnote_browser_runtime_src("tree-live-controller.js"),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"__TREE_LIVE_BOOTSTRAP__",
|
||||||
|
&escape_inline_json(&tree_live_bootstrap_json),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||||
@@ -2584,10 +2621,10 @@ mod tests {
|
|||||||
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
|
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
|
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
||||||
collect_filetree_render_rows, create_command_wire,
|
TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG,
|
||||||
};
|
};
|
||||||
use crate::app::{AppConfig, AppState, build_app};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::routes::command_support::build_runtime_command_plan;
|
use crate::routes::command_support::build_runtime_command_plan;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
@@ -2752,20 +2789,14 @@ mod tests {
|
|||||||
assert!(html.contains("data-rust-action=\"toggle\""));
|
assert!(html.contains("data-rust-action=\"toggle\""));
|
||||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
|
||||||
assert!(
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
TREE_SHELL_RUNTIME_JS
|
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||||
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
|
|
||||||
);
|
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
|
||||||
assert!(
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
TREE_SHELL_RUNTIME_JS
|
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||||
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
);
|
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||||
assert!(
|
|
||||||
TREE_SHELL_RUNTIME_JS
|
|
||||||
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
|
|
||||||
);
|
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||||
@@ -2790,11 +2821,8 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains("id=\"tree-shell-state\""));
|
assert!(html.contains("id=\"tree-shell-state\""));
|
||||||
assert!(
|
assert!(html
|
||||||
html.contains(
|
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""));
|
||||||
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
|
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
|
||||||
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
|
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
|
||||||
@@ -2829,23 +2857,17 @@ mod tests {
|
|||||||
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
|
||||||
assert!(
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
TREE_SHELL_RUNTIME_JS
|
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
|
||||||
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
);
|
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
|
||||||
assert!(
|
|
||||||
TREE_SHELL_RUNTIME_JS
|
|
||||||
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")
|
|
||||||
);
|
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
|
||||||
assert!(html.contains("tabindex=\""));
|
assert!(html.contains("tabindex=\""));
|
||||||
assert!(
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
TREE_SHELL_RUNTIME_JS
|
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
|
|
||||||
);
|
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2885,18 +2907,12 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||||
TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
.contains("function buildFileTreeMenuTarget(context"));
|
||||||
.contains("function buildFileTreeMenuTarget(context")
|
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
||||||
);
|
.contains("function createTreeShellFileTreeDndRuntime(context)"));
|
||||||
assert!(
|
assert!(TREE_SHELL_RENDER_RUNTIME_JS
|
||||||
TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||||
.contains("function createTreeShellFileTreeDndRuntime(context)")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
TREE_SHELL_RENDER_RUNTIME_JS
|
|
||||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
|
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
|
||||||
);
|
);
|
||||||
@@ -2904,10 +2920,8 @@ mod tests {
|
|||||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
|
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
|
||||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
|
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
|
||||||
assert!(
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
TREE_SHELL_RUNTIME_JS
|
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3065,8 +3079,16 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
|
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
||||||
|
assert!(html.contains("/api/mnote-browser-runtime/tree-live-controller.js"));
|
||||||
|
assert!(html.contains("data-mnote-root-uri="));
|
||||||
|
assert!(TREE_SHELL_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot"));
|
||||||
|
assert!(!TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
|
||||||
assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload"));
|
assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3120,8 +3142,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint()
|
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
|
||||||
{
|
) {
|
||||||
let root =
|
let root =
|
||||||
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
|
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
@@ -3236,12 +3258,11 @@ mod tests {
|
|||||||
String::from_utf8_lossy(&move_body)
|
String::from_utf8_lossy(&move_body)
|
||||||
);
|
);
|
||||||
assert!(!root.join("重命名页面").exists());
|
assert!(!root.join("重命名页面").exists());
|
||||||
assert!(
|
assert!(root
|
||||||
root.join("docs")
|
.join("docs")
|
||||||
.join("重命名页面")
|
.join("重命名页面")
|
||||||
.join("重命名页面.md")
|
.join("重命名页面.md")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
|
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
|
||||||
let moved_document_id = move_payload["result"]["documentId"]
|
let moved_document_id = move_payload["result"]["documentId"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -3281,12 +3302,11 @@ mod tests {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.expect("copied document id")
|
.expect("copied document id")
|
||||||
.to_string();
|
.to_string();
|
||||||
assert!(
|
assert!(root
|
||||||
root.join("docs")
|
.join("docs")
|
||||||
.join("重命名页面 2")
|
.join("重命名页面 2")
|
||||||
.join("重命名页面 2.md")
|
.join("重命名页面 2.md")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
|
|
||||||
let folder_response = app()
|
let folder_response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -3319,13 +3339,12 @@ mod tests {
|
|||||||
.expect("response");
|
.expect("response");
|
||||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||||
assert!(!root.join("docs").join("重命名页面").exists());
|
assert!(!root.join("docs").join("重命名页面").exists());
|
||||||
assert!(
|
assert!(root
|
||||||
root.join(".mnote")
|
.join(".mnote")
|
||||||
.join("trash")
|
.join("trash")
|
||||||
.join("重命名页面")
|
.join("重命名页面")
|
||||||
.join("重命名页面.md")
|
.join("重命名页面.md")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
assert!(root.join(".mnote").join("trash-index.json").exists());
|
assert!(root.join(".mnote").join("trash-index.json").exists());
|
||||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||||
|
|
||||||
@@ -3352,12 +3371,11 @@ mod tests {
|
|||||||
"{}",
|
"{}",
|
||||||
String::from_utf8_lossy(&restore_body)
|
String::from_utf8_lossy(&restore_body)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(root
|
||||||
root.join("docs")
|
.join("docs")
|
||||||
.join("重命名页面")
|
.join("重命名页面")
|
||||||
.join("重命名页面.md")
|
.join("重命名页面.md")
|
||||||
.exists()
|
.exists());
|
||||||
);
|
|
||||||
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
restore_payload["result"]["documentId"].as_str(),
|
restore_payload["result"]["documentId"].as_str(),
|
||||||
@@ -3573,12 +3591,10 @@ mod tests {
|
|||||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
assert_eq!(payload["ok"], false);
|
assert_eq!(payload["ok"], false);
|
||||||
assert_eq!(payload["code"], "local_folder_root_escape");
|
assert_eq!(payload["code"], "local_folder_root_escape");
|
||||||
assert!(
|
assert!(payload["message"]
|
||||||
payload["message"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("root")
|
.contains("root"));
|
||||||
);
|
|
||||||
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
|
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers
|
headers
|
||||||
@@ -3747,10 +3763,8 @@ mod tests {
|
|||||||
assert!(filetree_html.contains(
|
assert!(filetree_html.contains(
|
||||||
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
||||||
));
|
));
|
||||||
assert!(
|
assert!(filetree_html
|
||||||
filetree_html
|
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
||||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
|
|
||||||
);
|
|
||||||
assert!(filetree_html.contains(
|
assert!(filetree_html.contains(
|
||||||
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
|
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
|
||||||
));
|
));
|
||||||
@@ -3778,10 +3792,8 @@ mod tests {
|
|||||||
assert!(picker_html.contains(
|
assert!(picker_html.contains(
|
||||||
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
||||||
));
|
));
|
||||||
assert!(
|
assert!(picker_html
|
||||||
picker_html
|
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
||||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -4197,13 +4209,11 @@ mod tests {
|
|||||||
Some("convex://workspace/ws_demo")
|
Some("convex://workspace/ws_demo")
|
||||||
);
|
);
|
||||||
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
|
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
|
||||||
assert!(
|
assert!(create_wire
|
||||||
create_wire
|
|
||||||
.source
|
.source
|
||||||
.capabilities
|
.capabilities
|
||||||
.iter()
|
.iter()
|
||||||
.any(|capability| capability == "execute-command")
|
.any(|capability| capability == "execute-command"));
|
||||||
);
|
|
||||||
|
|
||||||
let rename_wire = create_command_wire(
|
let rename_wire = create_command_wire(
|
||||||
&context,
|
&context,
|
||||||
@@ -4395,31 +4405,21 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
|
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
|
||||||
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
|
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
|
||||||
assert!(
|
assert!(aliases
|
||||||
aliases
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|entry| entry.compat_command.starts_with("documents."))
|
.all(|entry| entry.compat_command.starts_with("documents.")));
|
||||||
);
|
assert!(aliases
|
||||||
assert!(
|
|
||||||
aliases
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|entry| entry.preferred_command.starts_with("tree."))
|
.all(|entry| entry.preferred_command.starts_with("tree.")));
|
||||||
);
|
assert!(aliases
|
||||||
assert!(
|
|
||||||
aliases
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|entry| entry.source_kind == "convex_workspace")
|
.all(|entry| entry.source_kind == "convex_workspace"));
|
||||||
);
|
assert!(aliases
|
||||||
assert!(
|
|
||||||
aliases
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|entry| entry.retained_for.contains("legacy cloud"))
|
.all(|entry| entry.retained_for.contains("legacy cloud")));
|
||||||
);
|
assert!(aliases
|
||||||
assert!(
|
|
||||||
aliases
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|entry| entry.retirement_condition.contains("emit tree."))
|
.all(|entry| entry.retirement_condition.contains("emit tree.")));
|
||||||
);
|
|
||||||
assert!(aliases.iter().any(|entry| {
|
assert!(aliases.iter().any(|entry| {
|
||||||
entry.compat_command == "documents.delete"
|
entry.compat_command == "documents.delete"
|
||||||
&& entry.preferred_command == "tree.node.archive"
|
&& entry.preferred_command == "tree.node.archive"
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ use crate::context::RequestContext;
|
|||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::gateway::current_actor_id;
|
use crate::routes::gateway::current_actor_id;
|
||||||
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
|
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
|
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
|
||||||
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
|
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
|
||||||
@@ -265,13 +265,11 @@ fn normalize_state(
|
|||||||
}
|
}
|
||||||
object.insert(
|
object.insert(
|
||||||
"scrollTop".to_string(),
|
"scrollTop".to_string(),
|
||||||
json!(
|
json!(object
|
||||||
object
|
|
||||||
.get("scrollTop")
|
.get("scrollTop")
|
||||||
.and_then(Value::as_f64)
|
.and_then(Value::as_f64)
|
||||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||||
.unwrap_or(0.0)
|
.unwrap_or(0.0)),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if !object.contains_key("updatedAtMs") {
|
if !object.contains_key("updatedAtMs") {
|
||||||
object.insert("updatedAtMs".to_string(), json!(0));
|
object.insert("updatedAtMs".to_string(), json!(0));
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ use crate::routes::gateway::current_actor_id;
|
|||||||
use crate::routes::local_folder_source::{
|
use crate::routes::local_folder_source::{
|
||||||
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
|
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
|
||||||
};
|
};
|
||||||
use axum::Json;
|
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_json::{Map, json};
|
use serde_json::{json, Map};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
|
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,13 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::stream_support::{
|
use crate::routes::stream_support::{
|
||||||
StreamSnapshotQuery, build_stream_push_delta_hint, load_stream_snapshot,
|
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
|
||||||
};
|
};
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
|
|
||||||
pub async fn socket(
|
pub async fn socket(
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ pub fn HomePage(
|
|||||||
/// 是否显示管理员授权入口
|
/// 是否显示管理员授权入口
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
show_admin_access_policy: bool,
|
show_admin_access_policy: bool,
|
||||||
|
/// 是否启用树实时流
|
||||||
|
#[prop(optional, default = true)]
|
||||||
|
enable_tree_live: bool,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let active_page_id = active_page_id.unwrap_or_default();
|
let active_page_id = active_page_id.unwrap_or_default();
|
||||||
let active_page_title = active_page_title
|
let active_page_title = active_page_title
|
||||||
@@ -50,7 +53,6 @@ pub fn HomePage(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
|
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
|
||||||
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
|
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
|
||||||
let enable_tree_live = false;
|
|
||||||
view! {
|
view! {
|
||||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
|
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
|
||||||
{move || if has_active_page {
|
{move || if has_active_page {
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
|
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
|
fn browser_runtime_src(asset: &str) -> String {
|
||||||
|
let base = format!("/api/mnote-browser-runtime/{asset}");
|
||||||
|
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
|
||||||
|
format!("{base}?devHot={cache_buster}")
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// MNOTE Wolai 风格页面布局
|
/// MNOTE Wolai 风格页面布局
|
||||||
///
|
///
|
||||||
/// 包含左侧栏 + 内容区的双栏布局。
|
/// 包含左侧栏 + 内容区的双栏布局。
|
||||||
@@ -152,17 +161,17 @@ pub fn PageLayout(
|
|||||||
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
||||||
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
||||||
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/resource-open-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/local-upload-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/filetree-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("filetree-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/filetree-selection-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("filetree-selection-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/filetree-context-menu-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("filetree-context-menu-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/filetree-dnd-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("filetree-dnd-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/filetree-keyboard-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("filetree-keyboard-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/sidebar-shell-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("sidebar-shell-runtime.js")}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/sidebar-tree-runtime.js"></script>
|
<script type="module" src={browser_runtime_src("sidebar-tree-runtime.js")}></script>
|
||||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||||
<script type="module" src="/api/mnote-browser-runtime/tree-live-controller.js"></script>
|
<script type="module" src={browser_runtime_src("tree-live-controller.js")}></script>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
|
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
|
||||||
<div class="mnote-main">
|
<div class="mnote-main">
|
||||||
@@ -385,6 +394,29 @@ mod tests {
|
|||||||
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() {
|
||||||
|
let _guard = crate::test_support::hermes_env_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("env lock");
|
||||||
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
|
let html = crate::ssr::render_view(leptos::view! {
|
||||||
|
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||||
|
<main>"正文"</main>
|
||||||
|
</super::PageLayout>
|
||||||
|
});
|
||||||
|
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
html.contains("/api/mnote-browser-runtime/tree-live-controller.js?devHot="),
|
||||||
|
"dev:hot 下 tree live controller URL 必须带 cache buster,避免浏览器继续执行旧 WS/SSE 逻辑"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js?devHot="),
|
||||||
|
"dev:hot 下 sidebar runtime URL 也必须带 cache buster"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() {
|
fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() {
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
|
||||||
@@ -587,12 +619,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() {
|
fn sidebar_tree_runtime_uses_local_folder_events_without_browser_reload() {
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("startLocalFolderSidebarWatch"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("startLocalFolderSidebarWatch"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot"));
|
||||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentWorkspaceId()"));
|
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentWorkspaceId()"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var rootUri = currentRootUri();"));
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var rootUri = currentRootUri();"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
|
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file"));
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
@@ -604,6 +635,18 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
|
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-disabled")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
TREE_LIVE_CONTROLLER_JS.contains("body.getAttribute('data-mnote-source-kind')"),
|
||||||
|
"根入口由 SSR body 暴露 local_folder 时,tree live 不能误走 retired Convex WS/SSE"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"),
|
||||||
|
"本地文件夹 bootstrap transport 应直接选择 /api/local-folder/events"
|
||||||
|
);
|
||||||
|
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
|
||||||
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.contains("fetch(window.location.href, { headers: { accept: 'text/html' } })"));
|
.contains("fetch(window.location.href, { headers: { accept: 'text/html' } })"));
|
||||||
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
|||||||
@@ -2011,12 +2011,20 @@ body {
|
|||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"] {
|
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"],
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-missing {
|
||||||
color: #9f1239 !important;
|
color: #9f1239 !important;
|
||||||
background: #fff1f2 !important;
|
background: #fff1f2 !important;
|
||||||
box-shadow: inset 0 0 0 1px #fecdd3 !important;
|
box-shadow: inset 0 0 0 1px #fecdd3 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-unauthorized="true"],
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-unauthorized {
|
||||||
|
color: #92400e !important;
|
||||||
|
background: #fffbeb !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #fde68a !important;
|
||||||
|
}
|
||||||
|
|
||||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before,
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before,
|
||||||
.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before {
|
.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before {
|
||||||
content: "" !important;
|
content: "" !important;
|
||||||
@@ -2029,10 +2037,16 @@ body {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12) !important;
|
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before {
|
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before,
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-missing::before {
|
||||||
background: #e11d48 !important;
|
background: #e11d48 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-unauthorized="true"]::before,
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-unauthorized::before {
|
||||||
|
background: #d97706 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-word::before {
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-word::before {
|
||||||
background: #4f7df3;
|
background: #4f7df3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ async function waitForVisibleText(page, text) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForAnyPageTreeNode(page, documentIdPrefix) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(prefix) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
|
||||||
|
.some((row) => {
|
||||||
|
const id = row.getAttribute("data-node-id") || "";
|
||||||
|
return id.startsWith(prefix);
|
||||||
|
}),
|
||||||
|
documentIdPrefix,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForGone(page, selector) {
|
async function waitForGone(page, selector) {
|
||||||
await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||||
}
|
}
|
||||||
@@ -84,6 +96,30 @@ async function waitForFileTreeRow(page, rowId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expandFileTreeFolder(page, rowId) {
|
||||||
|
const rowSelector = `.tree-row[data-row-id="${rowId}"]`;
|
||||||
|
await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(selector) => {
|
||||||
|
const row = document.querySelector(selector);
|
||||||
|
return !!row;
|
||||||
|
},
|
||||||
|
rowSelector,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const expanded = await page.locator(rowSelector).getAttribute("aria-expanded").catch(() => null);
|
||||||
|
if (expanded === "true") return;
|
||||||
|
await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(selector) => {
|
||||||
|
const row = document.querySelector(selector);
|
||||||
|
return row && row.getAttribute("aria-expanded") === "true";
|
||||||
|
},
|
||||||
|
rowSelector,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForFileTreeRowGone(page, rowId) {
|
async function waitForFileTreeRowGone(page, rowId) {
|
||||||
await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`);
|
await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`);
|
||||||
}
|
}
|
||||||
@@ -108,15 +144,18 @@ async function waitForPageTreeNodeGone(page, documentId) {
|
|||||||
|
|
||||||
async function waitForLocalFolderWatchProjectionApplied(page) {
|
async function waitForLocalFolderWatchProjectionApplied(page) {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") === "projection",
|
() => {
|
||||||
|
const value = document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") || "";
|
||||||
|
return value === "projection" || value === "watch_batch";
|
||||||
|
},
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
const appliedValue = await page.evaluate(
|
const appliedValue = await page.evaluate(
|
||||||
() => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied"),
|
() => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied"),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
appliedValue === "projection",
|
appliedValue === "projection" || appliedValue === "watch_batch",
|
||||||
`local_folder watch 应通过 projection 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`,
|
`local_folder 变更应通过事件驱动 projection/watch_batch 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +202,15 @@ async function run() {
|
|||||||
try {
|
try {
|
||||||
await quickLogin(page);
|
await quickLogin(page);
|
||||||
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
await waitForVisibleText(page, "Local Root");
|
await waitForAnyPageTreeNode(page, "local-md:");
|
||||||
await waitForVisibleText(page, "stable");
|
await waitForPageTreeNode(page, localMdDocumentId("docs/stable.md"));
|
||||||
await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), {
|
await page.waitForFunction(
|
||||||
timeout: UI_TIMEOUT_MS,
|
() => {
|
||||||
}).catch(() => {});
|
const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || "";
|
||||||
|
return transport === "local-folder-events";
|
||||||
|
},
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
navigationEvents.length = 0;
|
navigationEvents.length = 0;
|
||||||
|
|
||||||
@@ -195,10 +238,15 @@ async function run() {
|
|||||||
|
|
||||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
await waitForFileTreeRow(page, "local:folder:docs");
|
await waitForFileTreeRow(page, "local:folder:docs");
|
||||||
|
await expandFileTreeFolder(page, "local:folder:docs");
|
||||||
await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt");
|
await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt");
|
||||||
await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), {
|
await page.waitForFunction(
|
||||||
timeout: UI_TIMEOUT_MS,
|
() => {
|
||||||
}).catch(() => {});
|
const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || "";
|
||||||
|
return transport === "local-folder-events";
|
||||||
|
},
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
navigationEvents.length = 0;
|
navigationEvents.length = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ async function main() {
|
|||||||
evidence.restoreFocusDebug.localStorageKeysAfterFiletreeLoad = await page.evaluate(() => (
|
evidence.restoreFocusDebug.localStorageKeysAfterFiletreeLoad = await page.evaluate(() => (
|
||||||
Object.keys(window.localStorage || {}).filter((key) => key.indexOf("mnote") >= 0)
|
Object.keys(window.localStorage || {}).filter((key) => key.indexOf("mnote") >= 0)
|
||||||
)).catch(() => []);
|
)).catch(() => []);
|
||||||
// 等待轮询检测到变化后文件行重现(最长 1200ms polling + 180ms debounce)
|
// 等待事件驱动 tree live 刷新后文件行重现
|
||||||
try {
|
try {
|
||||||
await waitForFileTreeRow(page, assetRowIdStr);
|
await waitForFileTreeRow(page, assetRowIdStr);
|
||||||
evidence.fileReappearsInFiletree = true;
|
evidence.fileReappearsInFiletree = true;
|
||||||
@@ -367,11 +367,11 @@ async function main() {
|
|||||||
}
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
evidence.fileReappearsInFiletree, true,
|
evidence.fileReappearsInFiletree, true,
|
||||||
`restore 后 filetree 应通过轮询显示文件行(row-id: ${assetRowIdStr})`,
|
`restore 后 filetree 应通过事件驱动刷新显示文件行(row-id: ${assetRowIdStr})`,
|
||||||
);
|
);
|
||||||
evidence.steps.push({
|
evidence.steps.push({
|
||||||
step: 8,
|
step: 8,
|
||||||
label: "回到 filetree 页面后文件行重现(轮询更新)",
|
label: "回到 filetree 页面后文件行重现(事件刷新)",
|
||||||
ok: evidence.fileReappearsInFiletree,
|
ok: evidence.fileReappearsInFiletree,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ async function main() {
|
|||||||
let staleScopeChildrenRequests = 0;
|
let staleScopeChildrenRequests = 0;
|
||||||
let scopedRootProjectionRequests = 0;
|
let scopedRootProjectionRequests = 0;
|
||||||
let workspaceRootProjectionRequests = 0;
|
let workspaceRootProjectionRequests = 0;
|
||||||
let forceChangingWatchRevision = false;
|
|
||||||
let localWatchRevision = 0;
|
let localWatchRevision = 0;
|
||||||
|
|
||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
@@ -140,24 +139,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
await route.continue();
|
await route.continue();
|
||||||
});
|
});
|
||||||
await page.route("**/api/tree/local-folder-watch**", async (route) => {
|
|
||||||
if (!forceChangingWatchRevision) {
|
|
||||||
await route.continue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localWatchRevision += 1;
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
result: {
|
|
||||||
revision: `forced-watch-${localWatchRevision}`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
||||||
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||||
@@ -239,30 +220,40 @@ async function main() {
|
|||||||
relativePath,
|
relativePath,
|
||||||
expanded: row.getAttribute("aria-expanded"),
|
expanded: row.getAttribute("aria-expanded"),
|
||||||
selected: row.getAttribute("data-selected"),
|
selected: row.getAttribute("data-selected"),
|
||||||
|
focused: row.getAttribute("data-focused"),
|
||||||
active: row.getAttribute("data-active"),
|
active: row.getAttribute("data-active"),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
const firstMarkdownPath = firstParent + "/" + firstParent.split("/").pop() + ".md";
|
||||||
|
const secondMarkdownPath = secondParent + "/" + secondParent.split("/").pop() + ".md";
|
||||||
return {
|
return {
|
||||||
first: readRow(firstParent),
|
first: readRow(firstParent),
|
||||||
second: readRow(secondParent),
|
second: readRow(secondParent),
|
||||||
firstMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(firstParent + "/" + firstParent.split("/").pop() + ".md")}"]`)),
|
firstMarkdown: readRow(firstMarkdownPath),
|
||||||
secondMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(secondParent + "/" + secondParent.split("/").pop() + ".md")}"]`)),
|
secondMarkdown: readRow(secondMarkdownPath),
|
||||||
};
|
};
|
||||||
}, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath });
|
}, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
createExpansionState.first?.expanded,
|
createExpansionState.second?.expanded,
|
||||||
"false",
|
"true",
|
||||||
`连续新建页面不应把上一个页面包目录自动展开: ${JSON.stringify(createExpansionState)}`,
|
`新建页面后当前页面包目录应展开,避免焦点落到父文件夹: ${JSON.stringify(createExpansionState)}`,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
createExpansionState.second?.expanded,
|
createExpansionState.secondMarkdown?.selected,
|
||||||
"false",
|
"true",
|
||||||
`连续新建页面不应为了选中内部 md 而展开当前页面包目录: ${JSON.stringify(createExpansionState)}`,
|
`新建页面后应选中内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
createExpansionState.secondMarkdown?.focused,
|
||||||
|
"true",
|
||||||
|
`新建页面后文件树焦点应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
createExpansionState.secondMarkdown?.active,
|
||||||
|
"true",
|
||||||
|
`新建页面后 active 应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
|
||||||
);
|
);
|
||||||
assert.equal(createExpansionState.firstMarkdownVisible, false, `上一个页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`);
|
|
||||||
assert.equal(createExpansionState.secondMarkdownVisible, false, `当前页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`);
|
|
||||||
assert.equal(createExpansionState.second?.active, "true", `当前页面包目录应承接 active 状态: ${JSON.stringify(createExpansionState)}`);
|
|
||||||
|
|
||||||
const scopedUrl = new URL(baseUrl);
|
const scopedUrl = new URL(baseUrl);
|
||||||
if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId);
|
if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId);
|
||||||
@@ -341,13 +332,27 @@ async function main() {
|
|||||||
"scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection",
|
"scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection",
|
||||||
);
|
);
|
||||||
const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests;
|
const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests;
|
||||||
forceChangingWatchRevision = true;
|
localWatchRevision += 1;
|
||||||
await page.waitForTimeout(1600);
|
await page.evaluate((revision) => {
|
||||||
forceChangingWatchRevision = false;
|
window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", {
|
||||||
|
detail: {
|
||||||
|
payload: {
|
||||||
|
schema: "mnote.local_folder_watch_batch.v1",
|
||||||
|
sourceKind: "local_folder",
|
||||||
|
revision: `forced-watch-${revision}`,
|
||||||
|
affectedParents: [{ relativePath: "", reason: "coarse-watch" }],
|
||||||
|
changedPaths: [{ relativePath: "design", kind: "Modify(Name(Both))" }],
|
||||||
|
eventKinds: ["Modify(Name(Both))"],
|
||||||
|
fallbackResync: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, localWatchRevision);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
scopedRootProjectionRequests,
|
scopedRootProjectionRequests,
|
||||||
scopedRootRequestsBeforeCoarseWatch,
|
scopedRootRequestsBeforeCoarseWatch,
|
||||||
"scoped 文件树收到 coarse local-folder-watch revision 不应重拉 scope 根 projection",
|
"scoped 文件树收到 coarse local-folder 事件不应重拉 scope 根 projection",
|
||||||
);
|
);
|
||||||
|
|
||||||
const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]';
|
const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]';
|
||||||
|
|||||||
@@ -106,6 +106,50 @@ async function waitForEditorLinks(page, expectedCount) {
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertStandardMarkdownAttachmentLinks(links, label) {
|
||||||
|
assert(links.length > 0, `${label}: 应存在编辑器附件链接`);
|
||||||
|
for (const link of links) {
|
||||||
|
assert(!link.href.includes("/office-preview"), `${label}: 附件 href 不应持久写入 /office-preview: ${JSON.stringify(link)}`);
|
||||||
|
assert(!link.href.includes("/api/local-folder/files/open"), `${label}: 附件 href 不应持久写入本地 open API: ${JSON.stringify(link)}`);
|
||||||
|
assert(/^(?:\.{1,2}\/|[^:/?#]+(?:\/|$))/.test(link.href), `${label}: 附件 href 应为 Markdown 相对链接: ${JSON.stringify(link)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMarkdownSourceUsesStandardAttachmentLinks(root, relativePath, label) {
|
||||||
|
const markdown = fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||||
|
assert(!markdown.includes("/office-preview"), `${label}: Markdown 原文不应包含 /office-preview\n${markdown}`);
|
||||||
|
assert(!markdown.includes("/api/local-folder/files/open"), `${label}: Markdown 原文不应包含本地 open API\n${markdown}`);
|
||||||
|
assert(/\[[^\]]+\.pptx\]\(\.\/[^)]+\.pptx\)/i.test(markdown), `${label}: Markdown 原文应包含标准相对 PPTX 链接\n${markdown}`);
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPageAggregate(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||||
|
if (!script) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(script.textContent || "null");
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAttachmentRefProjection(page, expectedCount, label) {
|
||||||
|
const aggregate = await readPageAggregate(page);
|
||||||
|
const refs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : [];
|
||||||
|
assert(refs.length >= expectedCount, `${label}: attachmentRefs 数量不足: ${JSON.stringify(refs)}`);
|
||||||
|
const pptxRefs = refs.filter((ref) => /\.pptx$/i.test(String(ref.rawHref || "")));
|
||||||
|
assert(pptxRefs.length >= expectedCount, `${label}: attachmentRefs 应包含 PPTX: ${JSON.stringify(refs)}`);
|
||||||
|
for (const ref of pptxRefs.slice(0, expectedCount)) {
|
||||||
|
assert(/^\.\//.test(String(ref.rawHref || "")), `${label}: rawHref 应保持同目录 Markdown 相对链接: ${JSON.stringify(ref)}`);
|
||||||
|
assert.equal(ref.kind, "pageLocal", `${label}: 同目录附件应为 pageLocal: ${JSON.stringify(ref)}`);
|
||||||
|
assert.equal(ref.openKind, "office", `${label}: PPTX openKind 应为 office: ${JSON.stringify(ref)}`);
|
||||||
|
assert.equal(ref.authorized, true, `${label}: 当前授权 root 内附件应 authorized=true: ${JSON.stringify(ref)}`);
|
||||||
|
}
|
||||||
|
return pptxRefs;
|
||||||
|
}
|
||||||
|
|
||||||
async function assertNoConflict(page, label) {
|
async function assertNoConflict(page, label) {
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
const state = await page.evaluate(() => {
|
const state = await page.evaluate(() => {
|
||||||
@@ -403,23 +447,29 @@ async function main() {
|
|||||||
|
|
||||||
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
||||||
const firstLinks = await waitForEditorLinks(page, 1);
|
const firstLinks = await waitForEditorLinks(page, 1);
|
||||||
|
assertStandardMarkdownAttachmentLinks(firstLinks, "first-editor-upload");
|
||||||
const firstConflictState = await assertNoConflict(page, "first-editor-upload");
|
const firstConflictState = await assertNoConflict(page, "first-editor-upload");
|
||||||
assertLocalUploadSaveVersions(result, 1);
|
assertLocalUploadSaveVersions(result, 1);
|
||||||
result.states.push({ step: "first-editor-upload", links: firstLinks, conflictState: firstConflictState });
|
const firstMarkdown = assertMarkdownSourceUsesStandardAttachmentLinks(root, relativePath, "first-editor-upload");
|
||||||
|
result.states.push({ step: "first-editor-upload", links: firstLinks, conflictState: firstConflictState, markdown: firstMarkdown });
|
||||||
await typeLineAfterUpload(page, "第一份附件后的正文");
|
await typeLineAfterUpload(page, "第一份附件后的正文");
|
||||||
const afterFirstEditConflictState = await assertNoConflict(page, "after-first-upload-edit");
|
const afterFirstEditConflictState = await assertNoConflict(page, "after-first-upload-edit");
|
||||||
result.states.push({ step: "after-first-upload-edit", conflictState: afterFirstEditConflictState });
|
result.states.push({ step: "after-first-upload-edit", conflictState: afterFirstEditConflictState });
|
||||||
|
|
||||||
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
||||||
const secondLinks = await waitForEditorLinks(page, 2);
|
const secondLinks = await waitForEditorLinks(page, 2);
|
||||||
|
assertStandardMarkdownAttachmentLinks(secondLinks, "second-editor-upload");
|
||||||
const secondConflictState = await assertNoConflict(page, "second-editor-upload");
|
const secondConflictState = await assertNoConflict(page, "second-editor-upload");
|
||||||
assertLocalUploadSaveVersions(result, 2);
|
assertLocalUploadSaveVersions(result, 2);
|
||||||
|
const secondMarkdown = assertMarkdownSourceUsesStandardAttachmentLinks(root, relativePath, "second-editor-upload");
|
||||||
await delay(1800);
|
await delay(1800);
|
||||||
const editorRows = await waitForFileTreeAssetRows(page, 2);
|
const editorRows = await waitForFileTreeAssetRows(page, 2);
|
||||||
result.states.push({ step: "second-editor-upload", links: secondLinks, conflictState: secondConflictState, fileTreeRows: editorRows });
|
result.states.push({ step: "second-editor-upload", links: secondLinks, conflictState: secondConflictState, markdown: secondMarkdown, fileTreeRows: editorRows });
|
||||||
assert(editorRows.every((row) => row.localRelativePath), `文件树上传行缺少本地路径: ${JSON.stringify(editorRows)}`);
|
assert(editorRows.every((row) => row.localRelativePath), `文件树上传行缺少本地路径: ${JSON.stringify(editorRows)}`);
|
||||||
await openDocument(page, root, relativePath);
|
await openDocument(page, root, relativePath);
|
||||||
await waitForEditorLinks(page, 2);
|
assertStandardMarkdownAttachmentLinks(await waitForEditorLinks(page, 2), "reopen-editor-upload");
|
||||||
|
const attachmentRefs = await assertAttachmentRefProjection(page, 2, "reopen-editor-upload");
|
||||||
|
result.states.push({ step: "reopen-editor-upload", attachmentRefs });
|
||||||
await clickEditorPptxLink(page, context, fileName);
|
await clickEditorPptxLink(page, context, fileName);
|
||||||
await activatePrimaryPageTab(page);
|
await activatePrimaryPageTab(page);
|
||||||
await clickEditorPptxLink(page, context, fileName.replace(/\.pptx$/i, "-1.pptx"));
|
await clickEditorPptxLink(page, context, fileName.replace(/\.pptx$/i, "-1.pptx"));
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||||
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
|
||||||
|
const PDF_PATH = process.env.MNOTE_TASK506_PDF_PATH || "/home/lix/Downloads/ao2c06124.pdf";
|
||||||
|
const DOCX_PATH = process.env.MNOTE_TASK506_DOCX_PATH || "/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx";
|
||||||
|
const PNG_PATH = process.env.MNOTE_TASK506_PNG_PATH || "/home/lix/Downloads/image.png";
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task506-local-markdown-attachment-ref-matrix-smoke");
|
||||||
|
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||||
|
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||||
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||||
|
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
function fileUrl(localPath) {
|
||||||
|
return `file://${localPath.split(path.sep).map((part, index) => (
|
||||||
|
index === 0 ? "" : encodeURIComponent(part)
|
||||||
|
)).join("/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMdDocumentId(relativePath) {
|
||||||
|
return `local-md:${Buffer.from(relativePath, "utf8")
|
||||||
|
.toString("hex")
|
||||||
|
.replace(/../g, (hex) => {
|
||||||
|
const code = Number.parseInt(hex, 16);
|
||||||
|
const ch = String.fromCharCode(code);
|
||||||
|
return /[A-Za-z0-9._-]/.test(ch) ? ch : `~${hex.toUpperCase()}`;
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceId(ownerId) {
|
||||||
|
return `local-ws:${ownerId}:task506`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentUrl(root, relativePath) {
|
||||||
|
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||||
|
url.searchParams.set("sourceKind", "local_folder");
|
||||||
|
url.searchParams.set("rootUri", fileUrl(root));
|
||||||
|
url.searchParams.set("treeView", "filetree");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeWorkspaceManifest(root, ownerId) {
|
||||||
|
const metadataDir = path.join(root, ".mnote");
|
||||||
|
fs.mkdirSync(metadataDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(metadataDir, "workspace.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
workspaceId: workspaceId(ownerId),
|
||||||
|
ownerId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
if (await quickLoginButton.count()) {
|
||||||
|
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTestUser(context) {
|
||||||
|
const signIn = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
action: "auth:signIn",
|
||||||
|
args: {
|
||||||
|
provider: "password",
|
||||||
|
params: {
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
flow: "signIn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (signIn.ok()) return;
|
||||||
|
const signUp = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
action: "auth:signIn",
|
||||||
|
args: {
|
||||||
|
provider: "password",
|
||||||
|
params: {
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
username: "mnote-e2e",
|
||||||
|
name: "mnote-e2e",
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
flow: "signUp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(signUp.ok(), `测试用户创建失败: ${signUp.status()} ${await signUp.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDocument(page, root, relativePath) {
|
||||||
|
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAttachmentViaSlash(page, filePath) {
|
||||||
|
await activatePrimaryPageTab(page).catch(() => undefined);
|
||||||
|
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||||
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.keyboard.press("End").catch(() => undefined);
|
||||||
|
await page.keyboard.type("/");
|
||||||
|
const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first();
|
||||||
|
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const [fileChooser] = await Promise.all([
|
||||||
|
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||||
|
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||||
|
]);
|
||||||
|
await fileChooser.setFiles(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activatePrimaryPageTab(page) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === "function",
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
).catch(() => undefined);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === "function") {
|
||||||
|
window.__mnoteDocumentPaneRuntime.activatePageTab({ paneRole: "primary" });
|
||||||
|
}
|
||||||
|
}).catch(() => undefined);
|
||||||
|
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
|
||||||
|
if (await pageTab.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
await pageTab.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
await page.waitForFunction(() => !new URL(location.href).searchParams.get("resourceTab"), null, { timeout: UI_TIMEOUT_MS / 2 });
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const pagePanel = document.querySelector('[data-mnote-page-tab-panel][data-pane-role="primary"]');
|
||||||
|
const resourceHost = document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]');
|
||||||
|
const pageTab = document.querySelector('[data-mnote-main-tab="page"][data-pane-role="primary"]');
|
||||||
|
return pagePanel instanceof HTMLElement
|
||||||
|
&& pagePanel.hidden === false
|
||||||
|
&& (!(resourceHost instanceof HTMLElement) || resourceHost.hidden === true)
|
||||||
|
&& (!(pageTab instanceof HTMLElement) || pageTab.getAttribute("aria-selected") === "true");
|
||||||
|
}, null, { timeout: UI_TIMEOUT_MS / 2 });
|
||||||
|
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForNoUploadSaveError(page, label) {
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
lastSaveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
|
||||||
|
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||||
|
inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
|
||||||
|
}));
|
||||||
|
assert.equal(state.lastSaveError, "", `${label}: 上传保存不应失败 ${JSON.stringify(state)}`);
|
||||||
|
assert.equal(state.conflictVisible, false, `${label}: 上传不应触发冲突 ${JSON.stringify(state)}`);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAggregate(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||||
|
if (!script) return null;
|
||||||
|
return JSON.parse(script.textContent || "null");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bypassRuntimeAssetCache(context) {
|
||||||
|
await context.route("**/api/mnote-browser-runtime/**", async (route) => {
|
||||||
|
const headers = {
|
||||||
|
...route.request().headers(),
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
pragma: "no-cache",
|
||||||
|
};
|
||||||
|
await route.continue({ headers });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMarkdown(root, relativePath) {
|
||||||
|
return fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoRuntimeHref(markdown, label) {
|
||||||
|
assert(!markdown.includes("/office-preview"), `${label}: Markdown 不应包含 /office-preview\n${markdown}`);
|
||||||
|
assert(!markdown.includes("/api/local-folder/files/open"), `${label}: Markdown 不应包含本地 open API\n${markdown}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editorAttachmentLinks(page) {
|
||||||
|
return await page.evaluate(() => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
|
||||||
|
.map((link) => ({
|
||||||
|
text: link.textContent || "",
|
||||||
|
href: link.getAttribute("href") || "",
|
||||||
|
className: link.getAttribute("class") || "",
|
||||||
|
missing: link.getAttribute("data-mnote-attachment-missing") || "",
|
||||||
|
unauthorized: link.getAttribute("data-mnote-attachment-unauthorized") || "",
|
||||||
|
ariaLabel: link.getAttribute("aria-label") || "",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickAttachmentLink(page, text) {
|
||||||
|
await activatePrimaryPageTab(page);
|
||||||
|
const link = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a').filter({ hasText: text }).first();
|
||||||
|
await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
const visible = await link.evaluate((node) => {
|
||||||
|
if (!node) return false;
|
||||||
|
const scrollParent = (() => {
|
||||||
|
let current = node.parentElement;
|
||||||
|
while (current && current !== document.body && current !== document.documentElement) {
|
||||||
|
if (current.scrollHeight > current.clientHeight + 8) return current;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return document.scrollingElement || document.documentElement;
|
||||||
|
})();
|
||||||
|
if (typeof node.scrollIntoView === "function") {
|
||||||
|
node.scrollIntoView({ block: "center", inline: "nearest" });
|
||||||
|
}
|
||||||
|
const box = node.getBoundingClientRect();
|
||||||
|
const targetY = window.innerHeight / 2;
|
||||||
|
const deltaY = box.y + box.height / 2 - targetY;
|
||||||
|
if (Math.abs(deltaY) > 8 && scrollParent) {
|
||||||
|
scrollParent.scrollTop += deltaY;
|
||||||
|
}
|
||||||
|
const next = node.getBoundingClientRect();
|
||||||
|
return next.y >= 0 && next.y + next.height <= window.innerHeight;
|
||||||
|
});
|
||||||
|
if (visible) break;
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
}
|
||||||
|
await link.evaluate((node) => {
|
||||||
|
if (node && typeof node.scrollIntoView === "function") {
|
||||||
|
node.scrollIntoView({ block: "center", inline: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
const rect = await page.evaluate((needle) => {
|
||||||
|
const candidates = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'));
|
||||||
|
const node = candidates.find((item) => (item.textContent || "").includes(needle));
|
||||||
|
if (!node) return null;
|
||||||
|
const box = node.getBoundingClientRect();
|
||||||
|
const top = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
return {
|
||||||
|
x: box.x,
|
||||||
|
y: box.y,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
className: node.getAttribute("class") || "",
|
||||||
|
href: node.getAttribute("href") || "",
|
||||||
|
topTag: top ? top.tagName : "",
|
||||||
|
topText: top ? (top.textContent || "").slice(0, 80) : "",
|
||||||
|
topClass: top instanceof HTMLElement ? (top.getAttribute("class") || "") : "",
|
||||||
|
};
|
||||||
|
}, text);
|
||||||
|
assert(rect, `找不到可点击附件链接: ${text}`);
|
||||||
|
assert(
|
||||||
|
rect.y >= 0 && rect.y + rect.height <= rect.viewportHeight,
|
||||||
|
`附件链接未滚动到可点击视口内: ${text} ${JSON.stringify(rect)}`,
|
||||||
|
);
|
||||||
|
await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||||
|
await page.waitForTimeout(180);
|
||||||
|
return await page.evaluate((needle) => ({
|
||||||
|
needle,
|
||||||
|
blocked: document.documentElement.getAttribute("data-mnote-attachment-open-blocked") || "",
|
||||||
|
pageHidden: document.querySelector('[data-mnote-page-tab-panel][data-pane-role="primary"]')?.hidden ?? null,
|
||||||
|
resourceHidden: document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]')?.hidden ?? null,
|
||||||
|
activePageTab: document.querySelector('[data-mnote-main-tab="page"][data-pane-role="primary"]')?.getAttribute("aria-selected") || "",
|
||||||
|
}), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForActiveResource(page, text, label) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(needle) => {
|
||||||
|
const tab = document.querySelector('[data-testid="document-resource-tab"][data-active="true"], [data-mnote-resource-tab][data-active="true"]');
|
||||||
|
const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
|
||||||
|
const frame = panel?.querySelector?.('iframe.mnote-resource-tab-frame');
|
||||||
|
const frameSrc = frame instanceof HTMLIFrameElement ? decodeURIComponent(frame.getAttribute("src") || "") : "";
|
||||||
|
return (tab && (tab.textContent || "").includes(needle))
|
||||||
|
|| (panel && (panel.textContent || "").includes(needle))
|
||||||
|
|| frameSrc.includes(needle);
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `${label}.png`), fullPage: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAdminGrant(root, targetUserId) {
|
||||||
|
const adminContext = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||||
|
}).then((browser) => browser.newContext({
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
"x-mnote-actor-id": "mnote-e2e",
|
||||||
|
"x-mnote-actor-type": "admin",
|
||||||
|
},
|
||||||
|
}).then((context) => ({ browser, context })));
|
||||||
|
try {
|
||||||
|
const response = await adminContext.context.request.post(`${BASE_URL}/api/admin/access-policy/grants`, {
|
||||||
|
data: {
|
||||||
|
userId: targetUserId,
|
||||||
|
rootUri: fileUrl(root),
|
||||||
|
permission: "read",
|
||||||
|
recursive: true,
|
||||||
|
capabilities: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(response.ok(), `创建外部授权失败: ${response.status()} ${await response.text()}`);
|
||||||
|
} finally {
|
||||||
|
await adminContext.context.close().catch(() => undefined);
|
||||||
|
await adminContext.browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const fixture of [PDF_PATH, DOCX_PATH, PNG_PATH]) {
|
||||||
|
assert(fs.existsSync(fixture), `测试文件不存在: ${fixture}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-attachments-"));
|
||||||
|
const allowedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-allowed-"));
|
||||||
|
const deniedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-denied-"));
|
||||||
|
const relativePath = "中文页面/中文页面.md";
|
||||||
|
const docDir = path.join(root, "中文页面");
|
||||||
|
fs.mkdirSync(docDir, { recursive: true });
|
||||||
|
writeWorkspaceManifest(root, "mnote-e2e");
|
||||||
|
fs.writeFileSync(path.join(root, relativePath), "# Attachment Matrix\n\n正文\n", "utf8");
|
||||||
|
fs.writeFileSync(path.join(docDir, "manual.pdf"), Buffer.from("%PDF-1.4\n% manual\n", "utf8"));
|
||||||
|
fs.writeFileSync(path.join(docDir, "manual.png"), fs.readFileSync(PNG_PATH));
|
||||||
|
fs.mkdirSync(path.join(root, "assets"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "assets", "bad.pdf"), Buffer.from("%PDF-1.4\n% bad\n", "utf8"));
|
||||||
|
fs.writeFileSync(path.join(allowedRoot, "allowed.pdf"), Buffer.from("%PDF-1.4\n% allowed\n", "utf8"));
|
||||||
|
fs.writeFileSync(path.join(deniedRoot, "denied.pdf"), Buffer.from("%PDF-1.4\n% denied\n", "utf8"));
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.HEADFUL !== "1",
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1366, height: 900 },
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
"x-mnote-actor-id": "mnote-e2e",
|
||||||
|
"x-mnote-actor-type": "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await bypassRuntimeAssetCache(context);
|
||||||
|
let page = await context.newPage();
|
||||||
|
const result = { root, allowedRoot, deniedRoot, states: [], console: [], pageErrors: [] };
|
||||||
|
const attachPageDiagnostics = (targetPage) => {
|
||||||
|
targetPage.on("console", (message) => result.console.push({ type: message.type(), text: message.text() }));
|
||||||
|
targetPage.on("pageerror", (error) => result.pageErrors.push(String(error && error.stack || error)));
|
||||||
|
};
|
||||||
|
attachPageDiagnostics(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureTestUser(context);
|
||||||
|
await quickLogin(page);
|
||||||
|
await createAdminGrant(allowedRoot, "mnote-e2e");
|
||||||
|
await openDocument(page, root, relativePath);
|
||||||
|
|
||||||
|
await uploadAttachmentViaSlash(page, PDF_PATH);
|
||||||
|
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-last-upload-saved") === "true", null, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitForNoUploadSaveError(page, "pdf-upload");
|
||||||
|
const pdfName = path.basename(PDF_PATH);
|
||||||
|
const immediatePdfLinks = await editorAttachmentLinks(page);
|
||||||
|
assert(
|
||||||
|
immediatePdfLinks.some((link) => link.text.includes(pdfName) && link.missing !== "true"),
|
||||||
|
`中文页面上传 PDF 后刷新前不应标记 missing: ${JSON.stringify(immediatePdfLinks)}`,
|
||||||
|
);
|
||||||
|
result.states.push({ step: "click-uploaded-pdf-before-reload", click: await clickAttachmentLink(page, pdfName) });
|
||||||
|
await waitForActiveResource(page, pdfName, "00-uploaded-pdf-open-before-reload");
|
||||||
|
const immediatePdfOpenUrl = await page.evaluate(() => {
|
||||||
|
const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
|
||||||
|
const frame = panel?.querySelector?.('iframe.mnote-resource-tab-frame');
|
||||||
|
return frame instanceof HTMLIFrameElement ? decodeURIComponent(frame.getAttribute("src") || "") : "";
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
!immediatePdfOpenUrl.includes("~E"),
|
||||||
|
`中文 local-md 解码后不应把 ~E6... 写入 PDF open URL: ${immediatePdfOpenUrl}`,
|
||||||
|
);
|
||||||
|
await openDocument(page, root, relativePath);
|
||||||
|
await uploadAttachmentViaSlash(page, PNG_PATH);
|
||||||
|
await waitForNoUploadSaveError(page, "png-upload");
|
||||||
|
await openDocument(page, root, relativePath);
|
||||||
|
await uploadAttachmentViaSlash(page, DOCX_PATH);
|
||||||
|
await waitForNoUploadSaveError(page, "docx-upload");
|
||||||
|
|
||||||
|
let markdown = readMarkdown(root, relativePath);
|
||||||
|
assertNoRuntimeHref(markdown, "after-uploads");
|
||||||
|
assert(/\[[^\]]+\.pdf\]\(\.\/[^)]+\.pdf\)/i.test(markdown), `应保存标准 PDF 相对链接\n${markdown}`);
|
||||||
|
assert(/\[[^\]]+\.docx\]\(\.\/[^)]+\.docx\)/i.test(markdown), `应保存标准 DOCX 相对链接\n${markdown}`);
|
||||||
|
assert(/!\[[^\]]*\]\(\.\/[^)]+\.png\)/i.test(markdown), `应保存标准 PNG 图片链接\n${markdown}`);
|
||||||
|
assert(fs.existsSync(path.join(docDir, path.basename(PDF_PATH))), "PDF 应写入 md 同目录");
|
||||||
|
assert(fs.existsSync(path.join(docDir, path.basename(DOCX_PATH))), "DOCX 应写入 md 同目录");
|
||||||
|
assert(fs.existsSync(path.join(docDir, path.basename(PNG_PATH))), "PNG 应写入 md 同目录");
|
||||||
|
assert(!fs.existsSync(path.join(docDir, "Loose", path.basename(PDF_PATH))), "非 bundle md 不应写入同名子目录");
|
||||||
|
|
||||||
|
const manualAppend = [
|
||||||
|
"",
|
||||||
|
`[同目录手写](./manual.pdf)`,
|
||||||
|
``,
|
||||||
|
`[授权外部](file://${path.join(allowedRoot, "allowed.pdf")})`,
|
||||||
|
`[未授权外部](file://${path.join(deniedRoot, "denied.pdf")})`,
|
||||||
|
`[不支持上级目录](../assets/bad.pdf)`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
fs.appendFileSync(path.join(root, relativePath), manualAppend, "utf8");
|
||||||
|
await openDocument(page, root, relativePath);
|
||||||
|
const aggregate = await readAggregate(page);
|
||||||
|
const refs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : [];
|
||||||
|
const byLabel = (label) => refs.find((ref) => ref.label === label);
|
||||||
|
assert.equal(byLabel("同目录手写")?.authorized, true, `同目录手写应授权: ${JSON.stringify(refs)}`);
|
||||||
|
assert.equal(byLabel("授权外部")?.authorized, true, `授权外部应授权: ${JSON.stringify(refs)}`);
|
||||||
|
assert.equal(byLabel("未授权外部")?.authorized, false, `未授权外部应阻断: ${JSON.stringify(refs)}`);
|
||||||
|
assert.equal(byLabel("不支持上级目录")?.kind, "unknown", `../assets 应为 unknown: ${JSON.stringify(refs)}`);
|
||||||
|
|
||||||
|
result.states.push({ step: "click-manual", click: await clickAttachmentLink(page, "同目录手写") });
|
||||||
|
await waitForActiveResource(page, "manual.pdf", "01-manual-pdf-open");
|
||||||
|
result.states.push({ step: "click-authorized-external", click: await clickAttachmentLink(page, "授权外部") });
|
||||||
|
await waitForActiveResource(page, "allowed.pdf", "02-authorized-file-open");
|
||||||
|
result.states.push({ step: "click-unauthorized-external", click: await clickAttachmentLink(page, "未授权外部") });
|
||||||
|
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-attachment-open-blocked") === "unauthorized", null, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, "03-unauthorized-blocked.png"), fullPage: false });
|
||||||
|
|
||||||
|
fs.unlinkSync(path.join(docDir, pdfName));
|
||||||
|
await openDocument(page, root, relativePath);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(name) => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
|
||||||
|
.some((link) => (link.textContent || "").includes(name)
|
||||||
|
&& (link.getAttribute("data-mnote-attachment-missing") === "true"
|
||||||
|
|| link.classList.contains("mnote-uploaded-attachment-missing"))),
|
||||||
|
pdfName,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
result.states.push({ step: "click-missing-uploaded-pdf", click: await clickAttachmentLink(page, pdfName) });
|
||||||
|
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-attachment-open-blocked") === "missing", null, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, "04-missing-blocked.png"), fullPage: false });
|
||||||
|
|
||||||
|
await openDocument(page, root, relativePath);
|
||||||
|
const finalLinks = await editorAttachmentLinks(page);
|
||||||
|
markdown = readMarkdown(root, relativePath);
|
||||||
|
assertNoRuntimeHref(markdown, "final-markdown");
|
||||||
|
result.states.push({ step: "final", markdown, refs, links: finalLinks });
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, allowedRoot, deniedRoot }, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostics = await page.evaluate(() => ({
|
||||||
|
url: location.href,
|
||||||
|
blocked: document.documentElement.getAttribute("data-mnote-attachment-open-blocked") || "",
|
||||||
|
links: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')).map((link) => ({
|
||||||
|
text: link.textContent || "",
|
||||||
|
href: link.getAttribute("href") || "",
|
||||||
|
className: link.getAttribute("class") || "",
|
||||||
|
missing: link.getAttribute("data-mnote-attachment-missing") || "",
|
||||||
|
unauthorized: link.getAttribute("data-mnote-attachment-unauthorized") || "",
|
||||||
|
})),
|
||||||
|
aggregate: (() => {
|
||||||
|
const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||||
|
return script ? JSON.parse(script.textContent || "null") : null;
|
||||||
|
})(),
|
||||||
|
})).catch((err) => ({ diagnosticsError: String(err) }));
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
|
||||||
|
fs.writeFileSync(
|
||||||
|
RESULT_PATH,
|
||||||
|
`${JSON.stringify({ ok: false, ...result, diagnostics, error: String(error && error.stack || error) }, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error && error.stack ? error.stack : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user