From f7c1a0031ec459e2791567425f440b1841d836f8 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Fri, 24 Jul 2026 13:14:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20add=20note=20(=E5=B0=8F=E8=AE=B0)=20and?= =?UTF-8?q?=20board=20resource=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the spec-driven surface from 38 to 45 operations: - note list/get/create/update over GET/POST /notes and GET/PUT /notes/{id}, including the two non-standard envelopes (create's {success,data} and update's double-wrapped {data:{data}}), absorbed in the client layer - resource get/create/update over /yfm/boards (mindmap/flowchart/ architecturediagram DSL), with --doc-id/--url mutual exclusion and local JSON DSL validation - has_more-driven pagination helper for note list --all (page-number model, unlike the existing offset drain) - spec, generated types, compat layer, unit/e2e tests, help-surface golden, READMEs, AGENTS.md, CHANGELOG; version 1.2.0 Implemented by codex (gpt-5.6-sol); reviewed and red-light-verified. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 6 +- CHANGELOG.md | 11 + README.md | 11 +- README.zh-CN.md | 11 +- package-lock.json | 4 +- package.json | 2 +- spec/yuque-openapi.yaml | 613 ++++++++++++++++++++++++++ src/cli.ts | 4 + src/client/api/note.ts | 63 +++ src/client/api/resource.ts | 53 +++ src/client/paginate.ts | 17 + src/client/types.gen.ts | 536 ++++++++++++++++++++++ src/client/types.ts | 21 + src/commands/note.ts | 234 ++++++++++ src/commands/resource.ts | 190 ++++++++ tests/client/note-resource.test.ts | 121 +++++ tests/commands/note-resource.test.ts | 443 +++++++++++++++++++ tests/docs/help-surface.golden.json | 241 +++++++++- tests/e2e/note-resource.e2e.test.ts | 202 +++++++++ tests/e2e/schema-registry.e2e.test.ts | 42 +- tests/spec-constraints.test.ts | 171 ++++++- tests/spec-coverage.test.ts | 11 +- tests/utils/spec.ts | 35 ++ 23 files changed, 3015 insertions(+), 27 deletions(-) create mode 100644 src/client/api/note.ts create mode 100644 src/client/api/resource.ts create mode 100644 src/commands/note.ts create mode 100644 src/commands/resource.ts create mode 100644 tests/client/note-resource.test.ts create mode 100644 tests/commands/note-resource.test.ts create mode 100644 tests/e2e/note-resource.e2e.test.ts diff --git a/AGENTS.md b/AGENTS.md index 33d8f95..1cb4963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,8 @@ bin.ts → cli.ts (commander program, error → exit code) - `src/commands/book.ts` — `registerBookCommands` registers book list/get/create/update/delete, pagination, validation, and delete confirmation. - `src/commands/doc.ts` — `registerDocCommands` registers document CRUD, body/file handling, pagination, and version reads. - `src/commands/group.ts` — `registerGroupCommands` registers group-member list/set/remove, pagination, roles, and removal confirmation. +- `src/commands/note.ts` — `registerNoteCommands` registers note list/get/create/update, `has_more` pagination, file-backed content, and note rendering. +- `src/commands/resource.ts` — `registerResourceCommands` registers structured-board get/create/update, locator validation, and text/JSON DSL handling. - `src/commands/search.ts` — `registerSearchCommands` registers typed doc/book search and maps the book surface name to the API's `repo` value. - `src/commands/stats.ts` — `registerStatsCommands` registers aggregate/member/book/doc statistics, filters, sorting, and page draining. - `src/commands/toc.ts` — `registerTocCommands` registers TOC tree reads and cross-field-validated node updates. @@ -40,12 +42,14 @@ bin.ts → cli.ts (commander program, error → exit code) - `src/client/api/book.ts` — book owner collection and id-or-namespace item API wrappers. - `src/client/api/doc.ts` — document CRUD, global-id lookup, and published-version API wrappers. - `src/client/api/group.ts` — group-member list/update/remove API wrappers. +- `src/client/api/note.ts` — note CRUD-without-delete wrappers, including the create and double-wrapped update response quirks. +- `src/client/api/resource.ts` — structured-board read/create/update wrappers using the public wire field names. - `src/client/api/search.ts` — doc/repo search API wrapper. - `src/client/api/stats.ts` — group aggregate and paged member/book/doc statistics API wrappers, including the live-array correction to the spec types. - `src/client/api/toc.ts` — book TOC read/update API wrappers and update-body shape. - `src/client/api/user.ts` — heartbeat, current-user, and user-groups API wrappers. - `src/client/book-ref.ts` — parses a book reference as a numeric id or `group/slug` and produces the encoded `/repos/...` base path. -- `src/client/paginate.ts` — drains offset-paged endpoints for `--all` until the first short page. +- `src/client/paginate.ts` — drains offset-paged or explicit `has_more` endpoints for `--all`. - `src/client/types.gen.ts` — generated from `spec/yuque-openapi.yaml`; edit the spec and run `npm run gen:types`, never edit this file directly. - `src/client/types.ts` — thin compatibility adapter over the generated schemas; preserves public type names, live-API extensions, and index signatures for `--json` pass-through. - `spec/yuque-openapi.yaml` — vendored upstream OpenAPI contract and source of truth for the supported operation surface. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a69029..ccb5227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.2.0 + +- Added `note list/get/create/update` for Yuque notes (小记), including + `has_more` pagination, file-backed Markdown input, and the API's non-standard + create/update response envelopes. +- Added `resource get/create/update` for structured boards (mind maps, + flowcharts, and architecture diagrams), with document locator validation and + text/file DSL input. +- Extended the vendored OpenAPI contract, generated types, command/spec locks, + unit coverage, and always-on mock-server e2e coverage for all seven commands. + ## 1.1.0 Knowledge bases are now `book` across the CLI surface, aligning with Yuque's diff --git a/README.md b/README.md index 9ae7e13..fcc5b32 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A scriptable toolkit for the [Yuque (语雀)](https://www.yuque.com/) Open API [![CI][ci-image]][ci-url] [![npm version][npm-image]][npm-url] [![npm downloads][download-image]][download-url] [![License][license-image]][license-url] -[Quick Start](#quick-start) · [Commands](#commands-26) · [Scripting](#output--scripting) · [Troubleshooting](#troubleshooting) · [中文文档](./README.zh-CN.md) +[Quick Start](#quick-start) · [Commands](#commands-33) · [Scripting](#output--scripting) · [Troubleshooting](#troubleshooting) · [中文文档](./README.zh-CN.md) @@ -54,7 +54,7 @@ YUQUE_TOKEN=YOUR_TOKEN npx yuque-open-cli auth status Flags win over env vars, so a one-off `--token` override always works. Site roots are normalized (`/api/v2` is appended automatically); when unset, the host defaults to `https://www.yuque.com`. -## Commands (26) +## Commands (33) Each command maps to the [Yuque OpenAPI](https://www.yuque.com/yuque/developer/api) — the mapping is locked by a contract test against the vendored spec. @@ -77,6 +77,13 @@ Each command maps to the [Yuque OpenAPI](https://www.yuque.com/yuque/developer/a | | `doc delete ` | Delete a doc — asks for confirmation | | | `doc versions ` | List a doc's version history | | | `doc version ` | Show one version's content | +| **Notes** | `note list` | List notes (小记), with `--all` support | +| | `note get ` | Show a note with its full content | +| | `note create` | Create a note from `--body` or `--body-file` | +| | `note update ` | Update note source, HTML, abstract, and status | +| **Boards** | `resource get ` | Read a structured board from a document | +| | `resource create` | Create a mind map, flowchart, or architecture diagram | +| | `resource update ` | Update a board from text or JSON DSL | | **TOC** | `toc get ` | Print a book's table of contents as a tree | | | `toc update ` | Append, prepend, edit, or remove a TOC node | | **Groups** | `group members ` | List members of a group | diff --git a/README.zh-CN.md b/README.zh-CN.md index 4703e9a..6dd7fc7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ [![CI][ci-image]][ci-url] [![npm version][npm-image]][npm-url] [![npm downloads][download-image]][download-url] [![License][license-image]][license-url] -[快速开始](#快速开始) · [命令列表](#命令列表26-个) · [脚本化](#输出与脚本化) · [常见问题](#常见问题) · [English](./README.md) +[快速开始](#快速开始) · [命令列表](#命令列表33-个) · [脚本化](#输出与脚本化) · [常见问题](#常见问题) · [English](./README.md) @@ -54,7 +54,7 @@ YUQUE_TOKEN=YOUR_TOKEN npx yuque-open-cli auth status 命令行参数优先于环境变量,随手 `--token` 覆盖一次总是生效。站点地址会自动规范化(自动补 `/api/v2`);不设置时默认 `https://www.yuque.com`。 -## 命令列表(26 个) +## 命令列表(33 个) 每条命令都对应[语雀 OpenAPI](https://www.yuque.com/yuque/developer/api) —— 映射关系由契约测试锁定在内置规格文件上。 @@ -77,6 +77,13 @@ YUQUE_TOKEN=YOUR_TOKEN npx yuque-open-cli auth status | | `doc delete ` | 删除文档 —— 需要确认 | | | `doc versions ` | 列出文档的版本历史 | | | `doc version ` | 查看某个版本的内容 | +| **小记** | `note list` | 列出小记,支持 `--all` 拉取全量 | +| | `note get ` | 查看小记完整内容 | +| | `note create` | 从 `--body` 或 `--body-file` 创建小记 | +| | `note update ` | 更新小记源文本、HTML、摘要与状态 | +| **画板** | `resource get ` | 读取文档中的结构化画板 | +| | `resource create` | 创建思维导图、流程图或架构图 | +| | `resource update ` | 使用文本或 JSON DSL 更新画板 | | **目录** | `toc get ` | 以树形输出知识库目录 | | | `toc update ` | 追加、头插、编辑或删除目录节点 | | **团队** | `group members ` | 列出团队成员 | diff --git a/package-lock.json b/package-lock.json index a7e93cd..d9465e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "yuque-open-cli", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yuque-open-cli", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 807d246..c33562b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yuque-open-cli", - "version": "1.1.0", + "version": "1.2.0", "description": "Scriptable CLI toolkit for the Yuque (语雀) Open API — search, read, write, and manage docs from scripts, pipelines, and agents", "type": "module", "main": "dist/cli.js", diff --git a/spec/yuque-openapi.yaml b/spec/yuque-openapi.yaml index 590b870..e7a13e3 100644 --- a/spec/yuque-openapi.yaml +++ b/spec/yuque-openapi.yaml @@ -2178,6 +2178,208 @@ components: user: type: string description: 附件数量 + V2NoteContent: + type: object + properties: + updated_at: + type: string + format: date-time + description: 内容更新时间 + abstract: + type: string + description: 内容摘要 + format: + type: string + description: 内容格式 + source: + type: string + description: Markdown 源文本 + html: + type: string + description: HTML 内容 + draft_version: + type: integer + description: 草稿版本 + doc_dynamic_data: + type: array + description: 动态内容数据 + items: {} + V2Note: + type: object + properties: + id: + type: integer + format: int64 + description: 小记 ID + slug: + type: string + description: 小记路径 + doclet_id: + type: integer + format: int64 + description: Doclet ID + user_id: + type: integer + format: int64 + description: 用户 ID + content: + $ref: '#/components/schemas/V2NoteContent' + published_at: + type: string + format: date-time + description: 发布时间 + created_at: + type: string + format: date-time + description: 创建时间 + updated_at: + type: string + format: date-time + description: 更新时间 + deleted_at: + type: + - string + - 'null' + format: date-time + description: 删除时间 + pinned_at: + type: + - string + - 'null' + format: date-time + description: 置顶时间 + status: + type: integer + description: 小记状态 + save_from: + type: + - string + - 'null' + description: 保存来源 + public: + type: integer + description: 公开性 + likes_count: + type: integer + description: 点赞数 + comments_count: + type: integer + description: 评论数 + has_image: + type: boolean + description: 是否包含图片 + has_attachment: + type: boolean + description: 是否包含附件 + has_bookmark: + type: boolean + description: 是否包含书签 + word_count: + type: integer + description: 字数 + tags: + type: array + description: 标签 + items: + type: string + share_expired_time: + type: + - string + - 'null' + format: date-time + description: 分享过期时间 + V2NoteListResult: + type: object + properties: + pin_notes: + type: array + description: 置顶小记 + items: + $ref: '#/components/schemas/V2Note' + notes: + type: array + description: 普通小记 + items: + $ref: '#/components/schemas/V2Note' + has_more: + type: boolean + description: 是否还有下一页 + V2NoteCreateResult: + type: object + properties: + id: + type: integer + format: int64 + description: 小记 ID + slug: + type: string + description: 小记路径 + note_url: + type: string + description: 小记访问地址 + V2ResourceResult: + type: object + properties: + doc_id: + type: integer + format: int64 + description: 文档 ID + title: + type: string + description: 文档标题 + url: + type: string + description: 文档访问地址 + updated_at: + type: string + format: date-time + description: 更新时间 + board: + type: object + properties: + page_ref: + type: object + properties: + src: + type: string + description: 画板资源 ID + resource: + type: object + properties: + id: + type: + - string + - 'null' + description: 资源 ID + kind: + type: string + description: 资源类型 + dsl: + type: object + description: 画板 JSON DSL + additionalProperties: true + summary: + type: object + properties: + cell_count: + type: integer + description: 单元格数量 + type_counts: + type: object + description: 按类型统计 + additionalProperties: + type: integer + shape_counts: + type: object + description: 按形状统计 + additionalProperties: + type: integer + has_viewport: + type: boolean + description: 是否包含视口 + has_search: + type: boolean + description: 是否包含搜索 security: - authToken: [] paths: @@ -4389,6 +4591,413 @@ paths: '422': *ref_4 '429': *ref_5 '500': *ref_6 + /api/v2/notes: + get: + tags: + - note + summary: 获取当前用户的小记列表 + operationId: note_api_v2_note_list + description: |- + 获取当前用户的小记列表 + GET /api/v2/notes + parameters: + - name: status + in: query + description: 小记状态 + required: false + schema: + type: integer + - name: page + in: query + description: 页码 + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + description: 每页数量 + required: false + schema: + type: integer + minimum: 1 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/V2NoteListResult' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + post: + tags: + - note + summary: 创建小记 + operationId: note_api_v2_note_create + description: |- + 创建小记 + POST /api/v2/notes + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - body + properties: + body: + type: string + description: Markdown 正文 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + description: 是否创建成功 + data: + $ref: '#/components/schemas/V2NoteCreateResult' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/notes/{id}: + get: + tags: + - note + summary: 获取小记详情 + operationId: note_api_v2_note_show + description: |- + 获取小记详情 + GET /api/v2/notes/:id + parameters: + - name: id + in: path + description: 小记 ID + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/V2Note' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - note + summary: 更新小记 + operationId: note_api_v2_note_update + description: |- + 更新小记 + PUT /api/v2/notes/:id + + 注意:响应体是双层信封 `{ data: { data: Note } }`。 + parameters: + - name: id + in: path + description: 小记 ID + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - source + - html + - abstract + properties: + source: + type: string + description: Markdown 源文本 + html: + type: string + description: HTML 内容 + abstract: + type: string + description: 内容摘要 + status: + type: integer + description: 小记状态 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/V2Note' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/yfm/boards: + get: + tags: + - resource + summary: 获取文档中的结构化画板 + operationId: resource_api_v2_board_show + description: |- + 获取文档中的结构化画板 + GET /api/v2/yfm/boards + + doc_id 与 url 必须且只能提供一个。 + parameters: + - name: resource_type + in: query + description: 资源类型,目前只支持 board + required: true + schema: + type: string + enum: + - board + - name: src + in: query + description: 原始画板资源 ID + required: true + schema: + type: string + minLength: 1 + - name: doc_id + in: query + description: 文档 ID,与 url 二选一 + required: false + schema: + type: integer + minimum: 1 + - name: url + in: query + description: 文档 URL,与 doc_id 二选一 + required: false + schema: + type: string + minLength: 1 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/V2ResourceResult' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + post: + tags: + - resource + summary: 在文档中创建结构化画板 + operationId: resource_api_v2_board_create + description: |- + 在文档中创建结构化画板 + POST /api/v2/yfm/boards + + doc_id 与 url 必须且只能提供一个。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - type + - dsl + properties: + type: + type: string + enum: + - mindmap + - flowchart + - architecturediagram + description: 画板类型 + dsl: + type: string + description: 画板文本 DSL + doc_id: + type: integer + minimum: 1 + description: 文档 ID,与 url 二选一 + url: + type: string + minLength: 1 + description: 文档 URL,与 doc_id 二选一 + insert_after_lake_id: + type: string + minLength: 1 + description: 插入到指定顶层 Lake 节点之后 + oneOf: + - required: + - doc_id + not: + required: + - url + - required: + - url + not: + required: + - doc_id + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/V2ResourceResult' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - resource + summary: 更新文档中的结构化画板 + operationId: resource_api_v2_board_update + description: |- + 更新文档中的结构化画板 + PUT /api/v2/yfm/boards + + doc_id 与 url 必须且只能提供一个;text 与 dsl 必须且只能提供一个。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - src + properties: + src: + type: string + minLength: 1 + description: 原始画板资源 ID + doc_id: + type: integer + minimum: 1 + description: 文档 ID,与 url 二选一 + url: + type: string + minLength: 1 + description: 文档 URL,与 doc_id 二选一 + text: + type: string + description: 新的画板文本 DSL + dsl: + type: object + description: 画板 JSON DSL + additionalProperties: true + allOf: + - oneOf: + - required: + - doc_id + not: + required: + - url + - required: + - url + not: + required: + - doc_id + - oneOf: + - required: + - text + not: + required: + - dsl + - required: + - dsl + not: + required: + - text + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/V2ResourceResult' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 tags: - name: user description: user @@ -4402,3 +5011,7 @@ tags: description: repo - name: statistic description: statistic + - name: note + description: note + - name: resource + description: resource diff --git a/src/cli.ts b/src/cli.ts index 8eb3104..b3e3542 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,8 @@ import { registerDocCommands } from './commands/doc.js'; import { registerTocCommands } from './commands/toc.js'; import { registerGroupCommands } from './commands/group.js'; import { registerStatsCommands } from './commands/stats.js'; +import { registerNoteCommands } from './commands/note.js'; +import { registerResourceCommands } from './commands/resource.js'; const require = createRequire(import.meta.url); const { version: VERSION } = require('../package.json') as { version: string }; @@ -41,6 +43,8 @@ export function buildProgram(): Command { registerTocCommands(program); registerGroupCommands(program); registerStatsCommands(program); + registerNoteCommands(program); + registerResourceCommands(program); return program; } diff --git a/src/client/api/note.ts b/src/client/api/note.ts new file mode 100644 index 0000000..f662c54 --- /dev/null +++ b/src/client/api/note.ts @@ -0,0 +1,63 @@ +import type { YuqueHttp } from '../http.js'; +import type { ApiEnvelope, V2Note, V2NoteCreateResult, V2NoteListResult } from '../types.js'; + +export interface NoteListParams { + status?: number; + page?: number; + limit?: number; + [key: string]: unknown; +} + +export interface NoteCreatePayload { + body: string; +} + +export interface NoteUpdatePayload { + source: string; + html: string; + abstract: string; + status?: number; +} + +interface NoteCreateEnvelope { + success: boolean; + data: V2NoteCreateResult; + [key: string]: unknown; +} + +/** Drop undefined option values so omitted flags never reach the query string. */ +function compact(params: NoteListParams): Record { + return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined)); +} + +export async function listNotes( + http: YuqueHttp, + params: NoteListParams = {} +): Promise { + const res = await http.get>('/notes', compact(params)); + return res.data; +} + +export async function getNote(http: YuqueHttp, id: number): Promise { + const res = await http.get>(`/notes/${id}`); + return res.data; +} + +export async function createNote( + http: YuqueHttp, + payload: NoteCreatePayload +): Promise { + // POST /notes uses `{ success, data }`, not the standard API envelope. + const res = await http.post('/notes', payload); + return res.data; +} + +export async function updateNote( + http: YuqueHttp, + id: number, + payload: NoteUpdatePayload +): Promise { + // PUT /notes/:id is uniquely double-wrapped: `{ data: { data: } }`. + const res = await http.put>>(`/notes/${id}`, payload); + return res.data.data; +} diff --git a/src/client/api/resource.ts b/src/client/api/resource.ts new file mode 100644 index 0000000..d77f56a --- /dev/null +++ b/src/client/api/resource.ts @@ -0,0 +1,53 @@ +import type { YuqueHttp } from '../http.js'; +import type { ApiEnvelope, V2BoardDsl, V2BoardType, V2ResourceResult } from '../types.js'; + +export interface ResourceLocator { + doc_id?: number; + url?: string; +} + +export interface ResourceGetParams extends ResourceLocator { + resource_type: 'board'; + src: string; + [key: string]: unknown; +} + +export interface ResourceCreatePayload extends ResourceLocator { + type: V2BoardType; + dsl: string; + insert_after_lake_id?: string; +} + +export interface ResourceUpdatePayload extends ResourceLocator { + src: string; + text?: string; + dsl?: V2BoardDsl; +} + +function compact(value: T): Record { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + +export async function getResource( + http: YuqueHttp, + params: ResourceGetParams +): Promise { + const res = await http.get>('/yfm/boards', compact(params)); + return res.data; +} + +export async function createResource( + http: YuqueHttp, + payload: ResourceCreatePayload +): Promise { + const res = await http.post>('/yfm/boards', compact(payload)); + return res.data; +} + +export async function updateResource( + http: YuqueHttp, + payload: ResourceUpdatePayload +): Promise { + const res = await http.put>('/yfm/boards', compact(payload)); + return res.data; +} diff --git a/src/client/paginate.ts b/src/client/paginate.ts index 1bdd876..9ae68b0 100644 --- a/src/client/paginate.ts +++ b/src/client/paginate.ts @@ -13,3 +13,20 @@ export async function fetchAllPages( if (page.length < pageSize) return all; } } + +/** + * Drain a page-number endpoint whose response explicitly reports whether a + * next page exists. Unlike offset pagination, item counts do not determine + * completion; the server's `has_more` flag does. + */ +export async function fetchAllHasMorePages( + fetchPage: (page: number) => Promise, + firstPage = 1 +): Promise { + const pages: T[] = []; + for (let page = firstPage; ; page++) { + const result = await fetchPage(page); + pages.push(result); + if (!result.has_more) return pages; + } +} diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts index 3c840a7..ca7beca 100644 --- a/src/client/types.gen.ts +++ b/src/client/types.gen.ts @@ -706,6 +706,97 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/notes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 获取当前用户的小记列表 + * @description 获取当前用户的小记列表 + * GET /api/v2/notes + */ + get: operations["note_api_v2_note_list"]; + put?: never; + /** + * 创建小记 + * @description 创建小记 + * POST /api/v2/notes + */ + post: operations["note_api_v2_note_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/notes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 获取小记详情 + * @description 获取小记详情 + * GET /api/v2/notes/:id + */ + get: operations["note_api_v2_note_show"]; + /** + * 更新小记 + * @description 更新小记 + * PUT /api/v2/notes/:id + * + * 注意:响应体是双层信封 `{ data: { data: Note } }`。 + */ + put: operations["note_api_v2_note_update"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/yfm/boards": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 获取文档中的结构化画板 + * @description 获取文档中的结构化画板 + * GET /api/v2/yfm/boards + * + * doc_id 与 url 必须且只能提供一个。 + */ + get: operations["resource_api_v2_board_show"]; + /** + * 更新文档中的结构化画板 + * @description 更新文档中的结构化画板 + * PUT /api/v2/yfm/boards + * + * doc_id 与 url 必须且只能提供一个;text 与 dsl 必须且只能提供一个。 + */ + put: operations["resource_api_v2_board_update"]; + /** + * 在文档中创建结构化画板 + * @description 在文档中创建结构化画板 + * POST /api/v2/yfm/boards + * + * doc_id 与 url 必须且只能提供一个。 + */ + post: operations["resource_api_v2_board_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1798,6 +1889,162 @@ export interface components { /** @description 附件数量 */ user?: string; }; + V2NoteContent: { + /** + * Format: date-time + * @description 内容更新时间 + */ + updated_at?: string; + /** @description 内容摘要 */ + abstract?: string; + /** @description 内容格式 */ + format?: string; + /** @description Markdown 源文本 */ + source?: string; + /** @description HTML 内容 */ + html?: string; + /** @description 草稿版本 */ + draft_version?: number; + /** @description 动态内容数据 */ + doc_dynamic_data?: unknown[]; + }; + V2Note: { + /** + * Format: int64 + * @description 小记 ID + */ + id?: number; + /** @description 小记路径 */ + slug?: string; + /** + * Format: int64 + * @description Doclet ID + */ + doclet_id?: number; + /** + * Format: int64 + * @description 用户 ID + */ + user_id?: number; + content?: components["schemas"]["V2NoteContent"]; + /** + * Format: date-time + * @description 发布时间 + */ + published_at?: string; + /** + * Format: date-time + * @description 创建时间 + */ + created_at?: string; + /** + * Format: date-time + * @description 更新时间 + */ + updated_at?: string; + /** + * Format: date-time + * @description 删除时间 + */ + deleted_at?: string | null; + /** + * Format: date-time + * @description 置顶时间 + */ + pinned_at?: string | null; + /** @description 小记状态 */ + status?: number; + /** @description 保存来源 */ + save_from?: string | null; + /** @description 公开性 */ + public?: number; + /** @description 点赞数 */ + likes_count?: number; + /** @description 评论数 */ + comments_count?: number; + /** @description 是否包含图片 */ + has_image?: boolean; + /** @description 是否包含附件 */ + has_attachment?: boolean; + /** @description 是否包含书签 */ + has_bookmark?: boolean; + /** @description 字数 */ + word_count?: number; + /** @description 标签 */ + tags?: string[]; + /** + * Format: date-time + * @description 分享过期时间 + */ + share_expired_time?: string | null; + }; + V2NoteListResult: { + /** @description 置顶小记 */ + pin_notes?: components["schemas"]["V2Note"][]; + /** @description 普通小记 */ + notes?: components["schemas"]["V2Note"][]; + /** @description 是否还有下一页 */ + has_more?: boolean; + }; + V2NoteCreateResult: { + /** + * Format: int64 + * @description 小记 ID + */ + id?: number; + /** @description 小记路径 */ + slug?: string; + /** @description 小记访问地址 */ + note_url?: string; + }; + V2ResourceResult: { + /** + * Format: int64 + * @description 文档 ID + */ + doc_id?: number; + /** @description 文档标题 */ + title?: string; + /** @description 文档访问地址 */ + url?: string; + /** + * Format: date-time + * @description 更新时间 + */ + updated_at?: string; + board?: { + page_ref?: { + /** @description 画板资源 ID */ + src?: string; + }; + resource?: { + /** @description 资源 ID */ + id?: string | null; + /** @description 资源类型 */ + kind?: string; + }; + /** @description 画板 JSON DSL */ + dsl?: { + [key: string]: unknown; + }; + summary?: { + /** @description 单元格数量 */ + cell_count?: number; + /** @description 按类型统计 */ + type_counts?: { + [key: string]: number; + }; + /** @description 按形状统计 */ + shape_counts?: { + [key: string]: number; + }; + /** @description 是否包含视口 */ + has_viewport?: boolean; + /** @description 是否包含搜索 */ + has_search?: boolean; + }; + }; + }; }; responses: { /** @description 请求参数非法 */ @@ -2263,6 +2510,11 @@ export type V2GroupStatistics = components['schemas']['V2GroupStatistics']; export type V2MemberStatistics = components['schemas']['V2MemberStatistics']; export type V2BookStatistics = components['schemas']['V2BookStatistics']; export type V2DocStatistics = components['schemas']['V2DocStatistics']; +export type V2NoteContent = components['schemas']['V2NoteContent']; +export type V2Note = components['schemas']['V2Note']; +export type V2NoteListResult = components['schemas']['V2NoteListResult']; +export type V2NoteCreateResult = components['schemas']['V2NoteCreateResult']; +export type V2ResourceResult = components['schemas']['V2ResourceResult']; export type Response400 = components['responses']['400']; export type Response401 = components['responses']['401']; export type Response403 = components['responses']['403']; @@ -3759,4 +4011,288 @@ export interface operations { 500: components["responses"]["500"]; }; }; + note_api_v2_note_list: { + parameters: { + query?: { + /** @description 小记状态 */ + status?: number; + /** @description 页码 */ + page?: number; + /** @description 每页数量 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["V2NoteListResult"]; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; + note_api_v2_note_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Markdown 正文 */ + body: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description 是否创建成功 */ + success: boolean; + data: components["schemas"]["V2NoteCreateResult"]; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; + note_api_v2_note_show: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 小记 ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["V2Note"]; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; + note_api_v2_note_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 小记 ID */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Markdown 源文本 */ + source: string; + /** @description HTML 内容 */ + html: string; + /** @description 内容摘要 */ + abstract: string; + /** @description 小记状态 */ + status?: number; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + data: components["schemas"]["V2Note"]; + }; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; + resource_api_v2_board_show: { + parameters: { + query: { + /** @description 资源类型,目前只支持 board */ + resource_type: "board"; + /** @description 原始画板资源 ID */ + src: string; + /** @description 文档 ID,与 url 二选一 */ + doc_id?: number; + /** @description 文档 URL,与 doc_id 二选一 */ + url?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["V2ResourceResult"]; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; + resource_api_v2_board_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description 原始画板资源 ID */ + src: string; + /** @description 文档 ID,与 url 二选一 */ + doc_id?: number; + /** @description 文档 URL,与 doc_id 二选一 */ + url?: string; + /** @description 新的画板文本 DSL */ + text?: string; + /** @description 画板 JSON DSL */ + dsl?: { + [key: string]: unknown; + }; + } & ((unknown | unknown) & (unknown | unknown)); + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["V2ResourceResult"]; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; + resource_api_v2_board_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description 画板类型 + * @enum {string} + */ + type: "mindmap" | "flowchart" | "architecturediagram"; + /** @description 画板文本 DSL */ + dsl: string; + /** @description 文档 ID,与 url 二选一 */ + doc_id?: number; + /** @description 文档 URL,与 doc_id 二选一 */ + url?: string; + /** @description 插入到指定顶层 Lake 节点之后 */ + insert_after_lake_id?: string; + } & (unknown | unknown); + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["V2ResourceResult"]; + }; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 422: components["responses"]["422"]; + 429: components["responses"]["429"]; + 500: components["responses"]["500"]; + }; + }; } diff --git a/src/client/types.ts b/src/client/types.ts index 07bc120..aa723ec 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -61,3 +61,24 @@ export type V2GroupStatistics = PublicSchema<'V2GroupStatistics'>; export type V2MemberStatistics = PublicSchema<'V2MemberStatistics'>; export type V2BookStatistics = PublicSchema<'V2BookStatistics'>; export type V2DocStatistics = PublicSchema<'V2DocStatistics'>; +export type V2NoteContent = PublicSchema<'V2NoteContent'>; +export type V2Note = PublicSchemaWith<'V2Note', { content?: V2NoteContent }>; +export type V2NoteListResult = PublicSchemaWith< + 'V2NoteListResult', + { pin_notes?: V2Note[]; notes?: V2Note[] } +>; +export type V2NoteCreateResult = PublicSchema<'V2NoteCreateResult'>; + +export type V2BoardType = 'mindmap' | 'flowchart' | 'architecturediagram'; +export type V2BoardJsonScalar = string | number | boolean | null; +export type V2BoardJsonValue = + V2BoardJsonScalar | V2BoardJsonValue[] | { [key: string]: V2BoardJsonValue }; +export type V2BoardDsl = Record; + +type GeneratedResourceBoard = NonNullable['board']>; +export type V2ResourceResult = PublicSchemaWith< + 'V2ResourceResult', + { + board?: Omit & { dsl?: V2BoardDsl } & JsonPassthrough; + } +>; diff --git a/src/commands/note.ts b/src/commands/note.ts new file mode 100644 index 0000000..5613f9f --- /dev/null +++ b/src/commands/note.ts @@ -0,0 +1,234 @@ +import { readFileSync } from 'node:fs'; +import type { Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { printJson, printOk, printRecord, printTable, type Column } from '../output.js'; +import { fetchAllHasMorePages } from '../client/paginate.js'; +import { + createNote, + getNote, + listNotes, + updateNote, + type NoteUpdatePayload, +} from '../client/api/note.js'; +import type { V2Note, V2NoteListResult } from '../client/types.js'; + +const NOTE_FIELDS = [ + 'id', + 'slug', + 'content', + 'status', + 'tags', + 'word_count', + 'pinned_at', + 'published_at', + 'created_at', + 'updated_at', +]; + +const NOTE_COLUMNS: Column[] = [ + { key: 'id', header: 'ID' }, + { + key: 'content', + header: 'CONTENT', + format: (note) => { + const text = note.content?.source ?? note.content?.abstract ?? ''; + return text + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .slice(0, 80); + }, + }, + { key: 'word_count', header: 'WORDS' }, + { key: 'status', header: 'STATUS' }, + { + key: 'pinned_at', + header: 'PINNED', + format: (note) => (note.pinned_at ? 'yes' : ''), + }, + { key: 'updated_at', header: 'UPDATED' }, +]; + +interface FileBackedText { + value?: string; + file?: string; +} + +function readTextFile(path: string, flag: string): string { + try { + return readFileSync(path, 'utf8'); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new UsageError(`cannot read ${flag} ${path}: ${reason}`); + } +} + +function resolveText( + input: FileBackedText, + valueFlag: string, + fileFlag: string +): string | undefined { + if (input.value !== undefined && input.file !== undefined) { + throw new UsageError(`${valueFlag} and ${fileFlag} are mutually exclusive`); + } + return input.file === undefined ? input.value : readTextFile(input.file, fileFlag); +} + +function positiveInt(flag: string): (value: string) => number { + return (value) => { + if (!/^[1-9]\d*$/.test(value)) { + throw new UsageError(`${flag} expects a positive integer, got "${value}"`); + } + return Number(value); + }; +} + +function nonNegativeInt(flag: string): (value: string) => number { + return (value) => { + if (!/^\d+$/.test(value)) { + throw new UsageError(`${flag} expects a non-negative integer, got "${value}"`); + } + return Number(value); + }; +} + +function noteId(value: string): number { + return positiveInt('note id')(value); +} + +function mergeNotePages(pages: V2NoteListResult[]): V2NoteListResult { + return { + pin_notes: pages.flatMap((page) => page.pin_notes ?? []), + notes: pages.flatMap((page) => page.notes ?? []), + has_more: false, + }; +} + +function renderNoteList(result: V2NoteListResult): void { + printTable([...(result.pin_notes ?? []), ...(result.notes ?? [])], NOTE_COLUMNS); +} + +interface NoteListOptions { + status?: number; + page?: number; + limit?: number; + all?: boolean; +} + +interface NoteCreateOptions { + body?: string; + bodyFile?: string; +} + +interface NoteUpdateOptions { + source?: string; + sourceFile?: string; + html?: string; + abstract?: string; + status?: number; +} + +export function registerNoteCommands(program: Command): void { + const note = program.command('note').description('Work with notes (小记)'); + + const list = note + .command('list') + .description('List notes for the current user') + .option('--status ', 'filter by note status', nonNegativeInt('--status')) + .option('--page ', 'page number', positiveInt('--page')) + .option('--limit ', 'page size', positiveInt('--limit')) + .option('--all', 'fetch every page (starts at page 1)') + .action(async () => { + const opts = list.opts(); + const ctx = getContext(list); + const result = opts.all + ? mergeNotePages( + await fetchAllHasMorePages((page) => + listNotes(ctx.http, { status: opts.status, page, limit: opts.limit }) + ) + ) + : await listNotes(ctx.http, { + status: opts.status, + page: opts.page, + limit: opts.limit, + }); + if (ctx.json) { + printJson(result); + return; + } + renderNoteList(result); + }); + + const get = note + .command('get') + .description('Show a note with its full content') + .argument('', 'note id', noteId) + .action(async (id: number) => { + const ctx = getContext(get); + const result = await getNote(ctx.http, id); + if (ctx.json) { + printJson(result); + return; + } + printRecord(result, NOTE_FIELDS); + }); + + const create = note + .command('create') + .description('Create a note') + .option('--body ', 'note body in Markdown') + .option('--body-file ', 'read the note body from a file') + .action(async () => { + const opts = create.opts(); + const body = resolveText({ value: opts.body, file: opts.bodyFile }, '--body', '--body-file'); + if (body === undefined) { + throw new UsageError( + 'a note body is required — pass --body or --body-file ' + ); + } + const ctx = getContext(create); + const result = await createNote(ctx.http, { body }); + if (ctx.json) { + printJson(result); + return; + } + printOk( + `Created note ${result.slug ?? result.id ?? ''}${result.note_url ? `: ${result.note_url}` : ''}` + ); + }); + + const update = note + .command('update') + .description('Update a note') + .argument('', 'note id', noteId) + .option('--source ', 'Markdown source') + .option('--source-file ', 'read the Markdown source from a file') + .requiredOption('--html ', 'HTML content') + .requiredOption('--abstract ', 'content abstract') + .option('--status ', 'note status', nonNegativeInt('--status')) + .action(async (id: number, opts: NoteUpdateOptions) => { + const source = resolveText( + { value: opts.source, file: opts.sourceFile }, + '--source', + '--source-file' + ); + if (source === undefined) { + throw new UsageError( + 'note source is required — pass --source or --source-file ' + ); + } + const payload: NoteUpdatePayload = { + source, + html: opts.html as string, + abstract: opts.abstract as string, + ...(opts.status !== undefined && { status: opts.status }), + }; + const ctx = getContext(update); + const result = await updateNote(ctx.http, id, payload); + if (ctx.json) { + printJson(result); + return; + } + printRecord(result, NOTE_FIELDS); + }); +} diff --git a/src/commands/resource.ts b/src/commands/resource.ts new file mode 100644 index 0000000..c56893c --- /dev/null +++ b/src/commands/resource.ts @@ -0,0 +1,190 @@ +import { readFileSync } from 'node:fs'; +import { Option, type Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { printJson, printRecord } from '../output.js'; +import { + createResource, + getResource, + updateResource, + type ResourceLocator, +} from '../client/api/resource.js'; +import type { V2BoardDsl, V2BoardType, V2ResourceResult } from '../client/types.js'; + +const RESOURCE_FIELDS = ['doc_id', 'title', 'url', 'updated_at', 'board']; + +interface LocatorOptions { + docId?: number; + url?: string; +} + +interface DslOptions { + dsl?: string; + dslFile?: string; +} + +interface ResourceCreateOptions extends LocatorOptions, DslOptions { + type: V2BoardType; + insertAfterLakeId?: string; +} + +interface ResourceUpdateOptions extends LocatorOptions, DslOptions { + text?: string; +} + +function positiveInt(flag: string): (value: string) => number { + return (value) => { + if (!/^[1-9]\d*$/.test(value)) { + throw new UsageError(`${flag} expects a positive integer, got "${value}"`); + } + return Number(value); + }; +} + +function validateSrc(src: string): void { + if (src.includes('://')) { + throw new UsageError('src must be a raw board resource id, not a board:// locator'); + } +} + +function resolveLocator(opts: LocatorOptions): ResourceLocator { + const hasDocId = opts.docId !== undefined; + const hasUrl = opts.url !== undefined; + if (hasDocId === hasUrl) { + throw new UsageError('provide exactly one of --doc-id or --url'); + } + if (opts.url !== undefined && opts.url.trim() === '') { + throw new UsageError('--url must not be empty'); + } + return hasDocId ? { doc_id: opts.docId } : { url: opts.url }; +} + +function readDslFile(path: string): string { + try { + return readFileSync(path, 'utf8'); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new UsageError(`cannot read --dsl-file ${path}: ${reason}`); + } +} + +function resolveDsl(opts: DslOptions): string | undefined { + if (opts.dsl !== undefined && opts.dslFile !== undefined) { + throw new UsageError('--dsl and --dsl-file are mutually exclusive'); + } + return opts.dslFile === undefined ? opts.dsl : readDslFile(opts.dslFile); +} + +function parseDslObject(text: string): V2BoardDsl { + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new UsageError(`board DSL must be a valid JSON object: ${reason}`); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new UsageError('board DSL must be a JSON object'); + } + return value as V2BoardDsl; +} + +function boardTypeOption(): Option { + return new Option('--type ', 'board type') + .choices(['mindmap', 'flowchart', 'architecturediagram']) + .makeOptionMandatory(); +} + +function renderResource(result: V2ResourceResult): void { + printRecord(result, RESOURCE_FIELDS); +} + +function withLocatorOptions(command: Command): Command { + return command + .option('--doc-id ', 'locate the document by id', positiveInt('--doc-id')) + .option('--url ', 'locate the document by URL'); +} + +export function registerResourceCommands(program: Command): void { + const resource = program + .command('resource') + .description('Work with structured board resources (画板)'); + + const get = withLocatorOptions( + resource + .command('get') + .description('Show a structured board resource') + .argument('', 'raw board resource id') + ).action(async (src: string, opts: LocatorOptions) => { + validateSrc(src); + const locator = resolveLocator(opts); + const ctx = getContext(get); + const result = await getResource(ctx.http, { resource_type: 'board', src, ...locator }); + if (ctx.json) { + printJson(result); + return; + } + renderResource(result); + }); + + const create = withLocatorOptions( + resource + .command('create') + .description('Create a structured board resource') + .addOption(boardTypeOption()) + .option('--dsl ', 'board text DSL') + .option('--dsl-file ', 'read the board text DSL from a file') + .option('--insert-after-lake-id ', 'insert after this top-level Lake node') + ).action(async () => { + const opts = create.opts(); + const locator = resolveLocator(opts); + const dsl = resolveDsl(opts); + if (dsl === undefined) { + throw new UsageError('board DSL is required — pass --dsl or --dsl-file '); + } + const ctx = getContext(create); + const result = await createResource(ctx.http, { + type: opts.type, + dsl, + ...locator, + ...(opts.insertAfterLakeId !== undefined && { + insert_after_lake_id: opts.insertAfterLakeId, + }), + }); + if (ctx.json) { + printJson(result); + return; + } + renderResource(result); + }); + + const update = withLocatorOptions( + resource + .command('update') + .description('Update a structured board resource') + .argument('', 'raw board resource id') + .option('--text ', 'new board text DSL') + .option('--dsl ', 'board JSON DSL object') + .option('--dsl-file ', 'read the board JSON DSL object from a file') + ).action(async (src: string, opts: ResourceUpdateOptions) => { + validateSrc(src); + const locator = resolveLocator(opts); + const dslText = resolveDsl(opts); + if ((opts.text !== undefined) === (dslText !== undefined)) { + throw new UsageError('provide exactly one of --text or --dsl/--dsl-file'); + } + const content = + opts.text !== undefined ? { text: opts.text } : { dsl: parseDslObject(dslText as string) }; + const ctx = getContext(update); + const result = await updateResource(ctx.http, { + src, + ...locator, + ...content, + }); + if (ctx.json) { + printJson(result); + return; + } + renderResource(result); + }); +} diff --git a/tests/client/note-resource.test.ts b/tests/client/note-resource.test.ts new file mode 100644 index 0000000..9b18a58 --- /dev/null +++ b/tests/client/note-resource.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createNote, getNote, listNotes, updateNote } from '../../src/client/api/note.js'; +import { createResource, getResource, updateResource } from '../../src/client/api/resource.js'; +import { fetchAllHasMorePages } from '../../src/client/paginate.js'; +import type { YuqueHttp } from '../../src/client/http.js'; + +function httpWithSpies() { + const get = vi.fn(); + const post = vi.fn(); + const put = vi.fn(); + return { + http: { get, post, put } as unknown as YuqueHttp, + get, + post, + put, + }; +} + +describe('note API wrappers', () => { + it('unwraps standard list/detail envelopes and compacts list params', async () => { + const { http, get } = httpWithSpies(); + const page = { pin_notes: [], notes: [{ id: 1 }], has_more: false }; + get.mockResolvedValueOnce({ data: page }).mockResolvedValueOnce({ data: { id: 1 } }); + + await expect(listNotes(http, { status: 0, page: 2, limit: undefined })).resolves.toEqual(page); + expect(get).toHaveBeenNthCalledWith(1, '/notes', { status: 0, page: 2 }); + await expect(getNote(http, 1)).resolves.toEqual({ id: 1 }); + expect(get).toHaveBeenNthCalledWith(2, '/notes/1'); + }); + + it('explicitly unwraps POST /notes from its non-standard success envelope', async () => { + const { http, post } = httpWithSpies(); + const created = { id: 7, slug: 'n7', note_url: 'https://example.test/n7' }; + post.mockResolvedValueOnce({ success: true, data: created }); + + await expect(createNote(http, { body: '# note' })).resolves.toEqual(created); + expect(post).toHaveBeenCalledWith('/notes', { body: '# note' }); + }); + + it('explicitly unwraps PUT /notes/{id} from its double data envelope', async () => { + const { http, put } = httpWithSpies(); + const updated = { id: 7, status: 0, content: { source: '# updated' } }; + put.mockResolvedValueOnce({ data: { data: updated } }); + const payload = { source: '# updated', html: '

updated

', abstract: 'updated' }; + + await expect(updateNote(http, 7, payload)).resolves.toEqual(updated); + expect(put).toHaveBeenCalledWith('/notes/7', payload); + }); +}); + +describe('resource API wrappers', () => { + it('uses the standard envelope and exact GET board query names', async () => { + const { http, get } = httpWithSpies(); + const result = { doc_id: 9, title: 'Board' }; + get.mockResolvedValueOnce({ data: result }); + + await expect( + getResource(http, { + resource_type: 'board', + src: 'resource-id', + doc_id: 9, + url: undefined, + }) + ).resolves.toEqual(result); + expect(get).toHaveBeenCalledWith('/yfm/boards', { + resource_type: 'board', + src: 'resource-id', + doc_id: 9, + }); + }); + + it('posts and puts compact wire payloads', async () => { + const { http, post, put } = httpWithSpies(); + post.mockResolvedValueOnce({ data: { doc_id: 9 } }); + put.mockResolvedValueOnce({ data: { doc_id: 9, updated_at: 'now' } }); + + await expect( + createResource(http, { + type: 'mindmap', + dsl: 'root', + doc_id: 9, + insert_after_lake_id: undefined, + }) + ).resolves.toEqual({ doc_id: 9 }); + expect(post).toHaveBeenCalledWith('/yfm/boards', { + type: 'mindmap', + dsl: 'root', + doc_id: 9, + }); + + await expect( + updateResource(http, { + src: 'resource-id', + url: 'https://example.test/doc', + doc_id: undefined, + dsl: { cells: [] }, + }) + ).resolves.toEqual({ doc_id: 9, updated_at: 'now' }); + expect(put).toHaveBeenCalledWith('/yfm/boards', { + src: 'resource-id', + url: 'https://example.test/doc', + dsl: { cells: [] }, + }); + }); +}); + +describe('has_more pagination', () => { + it('increments page numbers until has_more is false', async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: [1], has_more: true }) + .mockResolvedValueOnce({ items: [2], has_more: false }); + + await expect(fetchAllHasMorePages(fetchPage, 3)).resolves.toEqual([ + { items: [1], has_more: true }, + { items: [2], has_more: false }, + ]); + expect(fetchPage).toHaveBeenNthCalledWith(1, 3); + expect(fetchPage).toHaveBeenNthCalledWith(2, 4); + }); +}); diff --git a/tests/commands/note-resource.test.ts b/tests/commands/note-resource.test.ts new file mode 100644 index 0000000..7ffcd4d --- /dev/null +++ b/tests/commands/note-resource.test.ts @@ -0,0 +1,443 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import axios, { type AxiosInstance } from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runCli } from '../../src/cli.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); +const request = vi.fn(); + +function argv(...args: string[]): string[] { + return ['node', 'yuque', ...args]; +} + +function ok(data: unknown) { + return { data: { data } }; +} + +let stdoutChunks: string[] = []; +let stderrChunks: string[] = []; + +beforeEach(() => { + request.mockReset(); + mockedAxios.create.mockReturnValue({ request } as unknown as AxiosInstance); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + stdoutChunks = []; + stderrChunks = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)); + return true; + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +const stdoutText = () => stdoutChunks.join(''); +const stderrText = () => stderrChunks.join(''); + +describe('note commands', () => { + const note = { + id: 7, + slug: 'n7', + content: { + source: '# Weekly note', + html: '

Weekly note

', + abstract: 'Weekly note', + }, + status: 0, + tags: ['weekly'], + word_count: 2, + pinned_at: null, + updated_at: '2026-07-24T00:00:00Z', + }; + + it('lists notes with status/page/limit and preserves the result object for --json', async () => { + const page = { pin_notes: [], notes: [note], has_more: false }; + request.mockResolvedValueOnce(ok(page)); + + await expect( + runCli(argv('note', 'list', '--status', '0', '--page', '2', '--limit', '10', '--json')) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/notes', + params: { status: 0, page: 2, limit: 10 }, + data: undefined, + }); + expect(JSON.parse(stdoutText())).toEqual(page); + }); + + it('--all follows has_more, increments page, and merges both note collections', async () => { + const pinned = { ...note, id: 1, slug: 'pinned', pinned_at: '2026-07-20T00:00:00Z' }; + request + .mockResolvedValueOnce( + ok({ pin_notes: [pinned], notes: [{ ...note, id: 2 }], has_more: true }) + ) + .mockResolvedValueOnce(ok({ pin_notes: [], notes: [{ ...note, id: 3 }], has_more: false })); + + await expect( + runCli(argv('note', 'list', '--status', '0', '--limit', '2', '--all', '--json')) + ).resolves.toBe(0); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ params: { status: 0, page: 1, limit: 2 } }) + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { status: 0, page: 2, limit: 2 } }) + ); + expect(JSON.parse(stdoutText())).toEqual({ + pin_notes: [pinned], + notes: [ + { ...note, id: 2 }, + { ...note, id: 3 }, + ], + has_more: false, + }); + }); + + it('renders pinned and normal notes in a human-readable table', async () => { + request.mockResolvedValueOnce( + ok({ + pin_notes: [{ ...note, pinned_at: '2026-07-20T00:00:00Z' }], + notes: [], + has_more: false, + }) + ); + await expect(runCli(argv('note', 'list'))).resolves.toBe(0); + expect(stdoutText()).toContain('CONTENT'); + expect(stdoutText()).toContain('# Weekly note'); + expect(stdoutText()).toContain('yes'); + }); + + it('gets a note and prints its record fields', async () => { + request.mockResolvedValueOnce(ok(note)); + await expect(runCli(argv('note', 'get', '7'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/notes/7', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toContain('Weekly note'); + expect(stdoutText()).toContain('word_count'); + }); + + it('creates a note from --body-file and unwraps the success envelope', async () => { + const dir = mkdtempSync(join(tmpdir(), 'yuque-note-')); + const file = join(dir, 'note.md'); + writeFileSync(file, '# from file\n'); + try { + const created = { id: 8, slug: 'n8', note_url: 'https://example.test/n8' }; + request.mockResolvedValueOnce({ data: { success: true, data: created } }); + await expect(runCli(argv('note', 'create', '--body-file', file))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'post', + url: '/notes', + params: undefined, + data: { body: '# from file\n' }, + }); + expect(stdoutText()).toContain('Created note n8'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('updates a note and explicitly consumes the double data envelope', async () => { + request.mockResolvedValueOnce({ data: { data: { data: note } } }); + await expect( + runCli( + argv( + 'note', + 'update', + '7', + '--source', + '# Weekly note', + '--html', + '

Weekly note

', + '--abstract', + 'Weekly note', + '--status', + '0', + '--json' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/notes/7', + params: undefined, + data: { + source: '# Weekly note', + html: '

Weekly note

', + abstract: 'Weekly note', + status: 0, + }, + }); + expect(JSON.parse(stdoutText())).toEqual(note); + }); + + it('supports --source-file for note updates', async () => { + const dir = mkdtempSync(join(tmpdir(), 'yuque-note-')); + const file = join(dir, 'source.md'); + writeFileSync(file, 'source text'); + try { + request.mockResolvedValueOnce({ data: { data: { data: note } } }); + await expect( + runCli( + argv( + 'note', + 'update', + '7', + '--source-file', + file, + '--html', + '

source text

', + '--abstract', + 'source text' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + source: 'source text', + html: '

source text

', + abstract: 'source text', + }, + }) + ); + expect(stdoutText()).toContain('Weekly note'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.each([ + { + args: ['note', 'list', '--page', '0'], + message: '--page expects a positive integer', + }, + { + args: ['note', 'list', '--status', '-1'], + message: '--status expects a non-negative integer', + }, + { + args: ['note', 'get', 'zero'], + message: 'note id expects a positive integer', + }, + { + args: ['note', 'create'], + message: 'a note body is required', + }, + { + args: ['note', 'create', '--body', 'x', '--body-file', 'x.md'], + message: 'mutually exclusive', + }, + { + args: ['note', 'update', '7', '--html', '

x

', '--abstract', 'x'], + message: 'note source is required', + }, + ])('rejects invalid note usage before making a request: $message', async ({ args, message }) => { + await expect(runCli(argv(...args))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain(message); + }); +}); + +describe('resource commands', () => { + const resource = { + doc_id: 9, + title: 'Planning board', + url: 'https://example.test/docs/9', + updated_at: '2026-07-24T00:00:00Z', + board: { + page_ref: { src: 'board-resource' }, + resource: { id: 'board-resource', kind: 'mindmap' }, + dsl: { cells: [] }, + summary: { + cell_count: 0, + type_counts: {}, + shape_counts: {}, + has_viewport: false, + has_search: false, + }, + }, + }; + + it('gets a board using resource_type=board, src, and one document locator', async () => { + request.mockResolvedValueOnce(ok(resource)); + await expect(runCli(argv('resource', 'get', 'board-resource', '--doc-id', '9'))).resolves.toBe( + 0 + ); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/yfm/boards', + params: { resource_type: 'board', src: 'board-resource', doc_id: 9 }, + data: undefined, + }); + expect(stdoutText()).toContain('Planning board'); + expect(stdoutText()).toContain('board-resource'); + }); + + it('creates a board from --dsl-file with exact wire field names', async () => { + const dir = mkdtempSync(join(tmpdir(), 'yuque-resource-')); + const file = join(dir, 'board.dsl'); + writeFileSync(file, 'root -> child\n'); + try { + request.mockResolvedValueOnce(ok(resource)); + await expect( + runCli( + argv( + 'resource', + 'create', + '--type', + 'flowchart', + '--dsl-file', + file, + '--url', + 'https://example.test/docs/9', + '--insert-after-lake-id', + 'lake-1', + '--json' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'post', + url: '/yfm/boards', + params: undefined, + data: { + type: 'flowchart', + dsl: 'root -> child\n', + url: 'https://example.test/docs/9', + insert_after_lake_id: 'lake-1', + }, + }); + expect(JSON.parse(stdoutText())).toEqual(resource); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('parses update --dsl as a JSON object and sends src in the body', async () => { + request.mockResolvedValueOnce(ok(resource)); + await expect( + runCli( + argv( + 'resource', + 'update', + 'board-resource', + '--doc-id', + '9', + '--dsl', + '{"cells":[{"id":"a"}]}', + '--json' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/yfm/boards', + params: undefined, + data: { + src: 'board-resource', + doc_id: 9, + dsl: { cells: [{ id: 'a' }] }, + }, + }); + expect(JSON.parse(stdoutText())).toEqual(resource); + }); + + it('updates a board from --text and renders the result', async () => { + request.mockResolvedValueOnce(ok(resource)); + await expect( + runCli( + argv( + 'resource', + 'update', + 'board-resource', + '--url', + 'https://example.test/docs/9', + '--text', + 'root -> child' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + src: 'board-resource', + url: 'https://example.test/docs/9', + text: 'root -> child', + }, + }) + ); + expect(stdoutText()).toContain('Planning board'); + }); + + it.each([ + { + args: ['resource', 'get', 'board-resource'], + message: 'exactly one of --doc-id or --url', + }, + { + args: [ + 'resource', + 'get', + 'board-resource', + '--doc-id', + '9', + '--url', + 'https://example.test/docs/9', + ], + message: 'exactly one of --doc-id or --url', + }, + { + args: ['resource', 'get', 'board://resource', '--doc-id', '9'], + message: 'raw board resource id', + }, + { + args: ['resource', 'create', '--type', 'mindmap', '--doc-id', '9'], + message: 'board DSL is required', + }, + { + args: ['resource', 'update', 'board-resource', '--doc-id', '9', '--text', 'x', '--dsl', '{}'], + message: 'exactly one of --text or --dsl/--dsl-file', + }, + { + args: ['resource', 'update', 'board-resource', '--doc-id', '9', '--dsl', '[]'], + message: 'board DSL must be a JSON object', + }, + { + args: ['resource', 'update', 'board-resource', '--doc-id', '9', '--dsl', '{'], + message: 'board DSL must be a valid JSON object', + }, + ])( + 'rejects invalid resource usage before making a request: $message', + async ({ args, message }) => { + await expect(runCli(argv(...args))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain(message); + } + ); +}); diff --git a/tests/docs/help-surface.golden.json b/tests/docs/help-surface.golden.json index 2027059..09e4dfd 100644 --- a/tests/docs/help-surface.golden.json +++ b/tests/docs/help-surface.golden.json @@ -188,12 +188,20 @@ { "flags": "--format ", "description": "content format", - "choices": ["markdown", "html", "lake"] + "choices": [ + "markdown", + "html", + "lake" + ] }, { "flags": "--public ", "description": "visibility (0 private, 1 public, 2 org-only)", - "choices": ["0", "1", "2"] + "choices": [ + "0", + "1", + "2" + ] }, { "flags": "--slug ", @@ -322,12 +330,20 @@ { "flags": "--format ", "description": "content format", - "choices": ["markdown", "html", "lake"] + "choices": [ + "markdown", + "html", + "lake" + ] }, { "flags": "--public ", "description": "visibility (0 private, 1 public, 2 org-only)", - "choices": ["0", "1", "2"] + "choices": [ + "0", + "1", + "2" + ] }, { "flags": "--slug ", @@ -415,7 +431,11 @@ { "flags": "--role ", "description": "role (0: admin, 1: member, 2: read-only) (required)", - "choices": ["0", "1", "2"] + "choices": [ + "0", + "1", + "2" + ] } ] }, @@ -442,7 +462,96 @@ { "flags": "--role ", "description": "filter by role (0: admin, 1: member, 2: read-only)", - "choices": ["0", "1", "2"] + "choices": [ + "0", + "1", + "2" + ] + } + ] + }, + { + "path": "note create", + "description": "Create a note", + "arguments": [], + "options": [ + { + "flags": "--body ", + "description": "note body in Markdown" + }, + { + "flags": "--body-file ", + "description": "read the note body from a file" + } + ] + }, + { + "path": "note get", + "description": "Show a note with its full content", + "arguments": [ + { + "name": "id", + "required": true, + "description": "note id", + "variadic": false + } + ], + "options": [] + }, + { + "path": "note list", + "description": "List notes for the current user", + "arguments": [], + "options": [ + { + "flags": "--all", + "description": "fetch every page (starts at page 1)" + }, + { + "flags": "--limit ", + "description": "page size" + }, + { + "flags": "--page ", + "description": "page number" + }, + { + "flags": "--status ", + "description": "filter by note status" + } + ] + }, + { + "path": "note update", + "description": "Update a note", + "arguments": [ + { + "name": "id", + "required": true, + "description": "note id", + "variadic": false + } + ], + "options": [ + { + "flags": "--abstract ", + "description": "content abstract" + }, + { + "flags": "--html ", + "description": "HTML content" + }, + { + "flags": "--source ", + "description": "Markdown source" + }, + { + "flags": "--source-file ", + "description": "read the Markdown source from a file" + }, + { + "flags": "--status ", + "description": "note status" } ] }, @@ -452,6 +561,98 @@ "arguments": [], "options": [] }, + { + "path": "resource create", + "description": "Create a structured board resource", + "arguments": [], + "options": [ + { + "flags": "--doc-id ", + "description": "locate the document by id" + }, + { + "flags": "--dsl ", + "description": "board text DSL" + }, + { + "flags": "--dsl-file ", + "description": "read the board text DSL from a file" + }, + { + "flags": "--insert-after-lake-id ", + "description": "insert after this top-level Lake node" + }, + { + "flags": "--type ", + "description": "board type", + "choices": [ + "mindmap", + "flowchart", + "architecturediagram" + ] + }, + { + "flags": "--url ", + "description": "locate the document by URL" + } + ] + }, + { + "path": "resource get", + "description": "Show a structured board resource", + "arguments": [ + { + "name": "src", + "required": true, + "description": "raw board resource id", + "variadic": false + } + ], + "options": [ + { + "flags": "--doc-id ", + "description": "locate the document by id" + }, + { + "flags": "--url ", + "description": "locate the document by URL" + } + ] + }, + { + "path": "resource update", + "description": "Update a structured board resource", + "arguments": [ + { + "name": "src", + "required": true, + "description": "raw board resource id", + "variadic": false + } + ], + "options": [ + { + "flags": "--doc-id ", + "description": "locate the document by id" + }, + { + "flags": "--dsl ", + "description": "board JSON DSL object" + }, + { + "flags": "--dsl-file ", + "description": "read the board JSON DSL object from a file" + }, + { + "flags": "--text ", + "description": "new board text DSL" + }, + { + "flags": "--url ", + "description": "locate the document by URL" + } + ] + }, { "path": "search", "description": "Search docs or books", @@ -657,12 +858,20 @@ { "flags": "--action ", "description": "operation: appendNode (append), prependNode (prepend), editNode, removeNode (required); move a node: appendNode/prependNode + --node-uuid", - "choices": ["appendNode", "prependNode", "editNode", "removeNode"] + "choices": [ + "appendNode", + "prependNode", + "editNode", + "removeNode" + ] }, { "flags": "--action-mode ", "description": "operation mode: sibling or child", - "choices": ["sibling", "child"] + "choices": [ + "sibling", + "child" + ] }, { "flags": "--doc-id ", @@ -679,7 +888,10 @@ { "flags": "--open-window ", "description": "open LINK in a new window (0: same page, 1: new)", - "choices": ["0", "1"] + "choices": [ + "0", + "1" + ] }, { "flags": "--target-uuid ", @@ -692,7 +904,11 @@ { "flags": "--type ", "description": "node type: DOC (document), LINK, TITLE (group)", - "choices": ["DOC", "LINK", "TITLE"] + "choices": [ + "DOC", + "LINK", + "TITLE" + ] }, { "flags": "--url ", @@ -701,7 +917,10 @@ { "flags": "--visible ", "description": "node visibility (0: hidden, 1: visible)", - "choices": ["0", "1"] + "choices": [ + "0", + "1" + ] } ] }, diff --git a/tests/e2e/note-resource.e2e.test.ts b/tests/e2e/note-resource.e2e.test.ts new file mode 100644 index 0000000..1529ae8 --- /dev/null +++ b/tests/e2e/note-resource.e2e.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FixtureServer } from './fixture-server.js'; +import { runYuque } from './run-cli.js'; + +let server: FixtureServer; +let host: string; + +beforeEach(async () => { + server = new FixtureServer(); + host = await server.start(); +}); + +afterEach(async () => { + await server.stop(); +}); + +describe('note commands', () => { + it('note list --all drains pages by has_more', async () => { + server.route('GET', '/api/v2/notes', (request) => + request.query.page === '1' + ? { + body: { + data: { + pin_notes: [{ id: 1, slug: 'pinned', pinned_at: '2026-07-20T00:00:00Z' }], + notes: [{ id: 2, slug: 'first' }], + has_more: true, + }, + }, + } + : { + body: { + data: { pin_notes: [], notes: [{ id: 3, slug: 'last' }], has_more: false }, + }, + } + ); + + const result = await runYuque(['note', 'list', '--all', '--limit', '2', '--json'], { host }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + pin_notes: [{ id: 1, slug: 'pinned', pinned_at: '2026-07-20T00:00:00Z' }], + notes: [ + { id: 2, slug: 'first' }, + { id: 3, slug: 'last' }, + ], + has_more: false, + }); + expect(server.requests.map((request) => request.query.page)).toEqual(['1', '2']); + }); + + it('note get renders the double-nested content object as a record', async () => { + server.route('GET', '/api/v2/notes/7', { + body: { + data: { + id: 7, + slug: 'weekly', + content: { source: '# Weekly', html: '

Weekly

', abstract: 'Weekly' }, + word_count: 1, + }, + }, + }); + const result = await runYuque(['note', 'get', '7'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('# Weekly'); + }); + + it('note create sends Markdown and consumes the non-standard success envelope', async () => { + server.route('POST', '/api/v2/notes', { + body: { + success: true, + data: { id: 8, slug: 'weekly', note_url: 'https://example.test/notes/weekly' }, + }, + }); + const result = await runYuque(['note', 'create', '--body', '# Weekly'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Created note weekly'); + expect(server.requests[0].body).toEqual({ body: '# Weekly' }); + }); + + it('note update sends all required fields and consumes the double data envelope', async () => { + server.route('PUT', '/api/v2/notes/7', { + body: { + data: { + data: { + id: 7, + slug: 'weekly', + content: { source: '# Updated', html: '

Updated

', abstract: 'Updated' }, + status: 0, + }, + }, + }, + }); + const result = await runYuque( + [ + 'note', + 'update', + '7', + '--source', + '# Updated', + '--html', + '

Updated

', + '--abstract', + 'Updated', + '--status', + '0', + '--json', + ], + { host } + ); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ id: 7, status: 0 }); + expect(server.requests[0].body).toEqual({ + source: '# Updated', + html: '

Updated

', + abstract: 'Updated', + status: 0, + }); + }); +}); + +describe('resource commands', () => { + const resultBody = { + data: { + doc_id: 9, + title: 'Architecture', + url: 'https://example.test/docs/9', + board: { + page_ref: { src: 'board-id' }, + resource: { id: 'board-id', kind: 'architecturediagram' }, + dsl: { cells: [] }, + summary: { + cell_count: 0, + type_counts: {}, + shape_counts: {}, + has_viewport: false, + has_search: false, + }, + }, + }, + }; + + it('resource get sends the fixed board resource_type and raw src query fields', async () => { + server.route('GET', '/api/v2/yfm/boards', { body: resultBody }); + const result = await runYuque(['resource', 'get', 'board-id', '--doc-id', '9', '--json'], { + host, + }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ doc_id: 9, title: 'Architecture' }); + expect(server.requests[0].query).toEqual({ + resource_type: 'board', + src: 'board-id', + doc_id: '9', + }); + }); + + it('resource create sends text DSL without a tool-layer resource_type field', async () => { + server.route('POST', '/api/v2/yfm/boards', { body: resultBody }); + const result = await runYuque( + [ + 'resource', + 'create', + '--type', + 'architecturediagram', + '--dsl', + 'service -> database', + '--url', + 'https://example.test/docs/9', + ], + { host } + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Architecture'); + expect(server.requests[0].body).toEqual({ + type: 'architecturediagram', + dsl: 'service -> database', + url: 'https://example.test/docs/9', + }); + }); + + it('resource update sends src and parsed JSON DSL in the body', async () => { + server.route('PUT', '/api/v2/yfm/boards', { body: resultBody }); + const result = await runYuque( + [ + 'resource', + 'update', + 'board-id', + '--doc-id', + '9', + '--dsl', + '{"cells":[{"id":"service"}]}', + '--json', + ], + { host } + ); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ doc_id: 9 }); + expect(server.requests[0].body).toEqual({ + src: 'board-id', + doc_id: 9, + dsl: { cells: [{ id: 'service' }] }, + }); + }); +}); diff --git a/tests/e2e/schema-registry.e2e.test.ts b/tests/e2e/schema-registry.e2e.test.ts index 96de460..e520c92 100644 --- a/tests/e2e/schema-registry.e2e.test.ts +++ b/tests/e2e/schema-registry.e2e.test.ts @@ -6,9 +6,9 @@ describe('fixture schema registry', () => { it('indexes and compiles the OpenAPI operation schemas once', () => { expect(registry.summary).toEqual({ - operations: 38, - requestSchemas: 11, - responseSchemas: 38, + operations: 45, + requestSchemas: 15, + responseSchemas: 45, }); }); @@ -65,6 +65,42 @@ describe('fixture schema registry', () => { ).toBe(true); }); + it('validates note envelope quirks and board locator/update bodies', () => { + expect( + registry.validateResponse('POST', '/api/v2/notes', 200, { + success: true, + data: { id: 7, slug: 'n7', note_url: 'https://example.test/n7' }, + }) + ).toBe(true); + expect( + registry.validateResponse('PUT', '/api/v2/notes/7', 200, { + data: { data: { id: 7, slug: 'n7' } }, + }) + ).toBe(true); + expect( + registry.validateRequest('POST', '/api/v2/yfm/boards', { + type: 'mindmap', + dsl: 'root', + doc_id: 9, + }) + ).toBe(true); + expect( + registry.validateRequest('PUT', '/api/v2/yfm/boards', { + src: 'board-id', + url: 'https://example.test/docs/9', + dsl: { cells: [] }, + }) + ).toBe(true); + expect(() => + registry.validateRequest('PUT', '/api/v2/yfm/boards', { + src: 'board-id', + doc_id: 9, + url: 'https://example.test/docs/9', + text: 'root', + }) + ).toThrowError(/Fixture request schema validation failed/); + }); + it('skips routes and response statuses with no JSON schema', () => { expect( registry.validateResponse('GET', '/api/v2/user', 401, { diff --git a/tests/spec-constraints.test.ts b/tests/spec-constraints.test.ts index 345e66e..53c361a 100644 --- a/tests/spec-constraints.test.ts +++ b/tests/spec-constraints.test.ts @@ -736,6 +736,164 @@ const CONSTRAINT_PINS: Record = { cli: flagCli('stats docs', '--sort-order', ['stats', 'docs', 'team'], 'params', 'sortOrder'), }, ], + note_api_v2_note_list: [ + { + in: 'query', + param: 'page', + minimum: 1, + cli: flagCli('note list', '--page', ['note', 'list'], 'params', 'page'), + }, + { + in: 'query', + param: 'limit', + minimum: 1, + cli: flagCli('note list', '--limit', ['note', 'list'], 'params', 'limit'), + }, + ], + note_api_v2_note_create: [ + { + in: 'body', + param: 'body', + required: true, + cli: flagCli('note create', '--body', ['note', 'create'], 'data', 'body'), + cliAlternativeFlags: ['--body-file'], + }, + ], + note_api_v2_note_show: [], + note_api_v2_note_update: [ + { + in: 'body', + param: 'source', + required: true, + cli: flagCli( + 'note update', + '--source', + ['note', 'update', '1', '--html', '

x

', '--abstract', 'x'], + 'data', + 'source' + ), + cliAlternativeFlags: ['--source-file'], + }, + { + in: 'body', + param: 'html', + required: true, + cli: flagCli( + 'note update', + '--html', + ['note', 'update', '1', '--source', 'x', '--abstract', 'x'], + 'data', + 'html' + ), + }, + { + in: 'body', + param: 'abstract', + required: true, + cli: flagCli( + 'note update', + '--abstract', + ['note', 'update', '1', '--source', 'x', '--html', '

x

'], + 'data', + 'abstract' + ), + }, + ], + resource_api_v2_board_show: [ + { + in: 'query', + param: 'resource_type', + required: true, + enum: ['board'], + cli: null, + }, + { + in: 'query', + param: 'src', + required: true, + cli: argumentCli( + 'resource get', + '', + ['resource', 'get'], + ['--doc-id', '1'], + 'params', + 'src' + ), + }, + { + in: 'query', + param: 'doc_id', + minimum: 1, + cli: flagCli('resource get', '--doc-id', ['resource', 'get', 'raw-id'], 'params', 'doc_id'), + }, + ], + resource_api_v2_board_create: [ + { + in: 'body', + param: 'type', + required: true, + enum: ['mindmap', 'flowchart', 'architecturediagram'], + cli: flagCli( + 'resource create', + '--type', + ['resource', 'create', '--doc-id', '1', '--dsl', 'root'], + 'data', + 'type' + ), + }, + { + in: 'body', + param: 'dsl', + required: true, + cli: flagCli( + 'resource create', + '--dsl', + ['resource', 'create', '--doc-id', '1', '--type', 'mindmap'], + 'data', + 'dsl' + ), + cliAlternativeFlags: ['--dsl-file'], + }, + { + in: 'body', + param: 'doc_id', + minimum: 1, + cli: flagCli( + 'resource create', + '--doc-id', + ['resource', 'create', '--type', 'mindmap', '--dsl', 'root'], + 'data', + 'doc_id' + ), + }, + ], + resource_api_v2_board_update: [ + { + in: 'body', + param: 'src', + required: true, + cli: argumentCli( + 'resource update', + '', + ['resource', 'update'], + ['--doc-id', '1', '--text', 'root'], + 'data', + 'src' + ), + }, + { + in: 'body', + param: 'doc_id', + minimum: 1, + cli: flagCli( + 'resource update', + '--doc-id', + ['resource', 'update', 'raw-id', '--text', 'root'], + 'data', + 'doc_id' + ), + }, + ], }; function isObject(value: unknown): value is OpenApiObject { @@ -919,9 +1077,16 @@ describe('spec parameter constraints contract', () => { describe('CLI boundary alignment', () => { beforeEach(() => { request.mockReset(); - request.mockImplementation((config: { url?: string }) => - Promise.resolve({ data: { data: successData(config.url) } }) - ); + request.mockImplementation((config: { method?: string; url?: string }) => { + const data = successData(config.url); + if (config.method === 'put' && config.url?.startsWith('/notes/')) { + return Promise.resolve({ data: { data: { data } } }); + } + if (config.method === 'post' && config.url === '/notes') { + return Promise.resolve({ data: { success: true, data } }); + } + return Promise.resolve({ data: { data } }); + }); mockedAxios.create.mockReset(); mockedAxios.create.mockReturnValue({ request } as unknown as AxiosInstance); vi.stubEnv('YUQUE_TOKEN', 'test-token'); diff --git a/tests/spec-coverage.test.ts b/tests/spec-coverage.test.ts index 04f22d1..9fae1ca 100644 --- a/tests/spec-coverage.test.ts +++ b/tests/spec-coverage.test.ts @@ -42,6 +42,13 @@ const EXPECTED_LEAF_COMMANDS = [ 'stats members', 'stats books', 'stats docs', + 'note list', + 'note get', + 'note create', + 'note update', + 'resource get', + 'resource create', + 'resource update', ].sort(); function collectLeafCommands(command: Command, prefix: string[] = []): string[] { @@ -63,8 +70,8 @@ describe('spec coverage contract', () => { const { operations, missingOperationIds } = loadSpecOperations(); const registeredLeaves = collectLeafCommands(buildProgram()).sort(); - it('pins the spec identity (38 operations)', () => { - expect(operations).toHaveLength(38); + it('pins the spec identity (45 operations)', () => { + expect(operations).toHaveLength(45); }); it('has no spec operation without an operationId', () => { diff --git a/tests/utils/spec.ts b/tests/utils/spec.ts index 91bcce8..08bb1fb 100644 --- a/tests/utils/spec.ts +++ b/tests/utils/spec.ts @@ -214,6 +214,41 @@ export const OPERATION_TO_COMMANDS: Record = { path: '/api/v2/groups/{login}/statistics/docs', commands: ['stats docs'], }, + note_api_v2_note_list: { + method: 'get', + path: '/api/v2/notes', + commands: ['note list'], + }, + note_api_v2_note_create: { + method: 'post', + path: '/api/v2/notes', + commands: ['note create'], + }, + note_api_v2_note_show: { + method: 'get', + path: '/api/v2/notes/{id}', + commands: ['note get'], + }, + note_api_v2_note_update: { + method: 'put', + path: '/api/v2/notes/{id}', + commands: ['note update'], + }, + resource_api_v2_board_show: { + method: 'get', + path: '/api/v2/yfm/boards', + commands: ['resource get'], + }, + resource_api_v2_board_create: { + method: 'post', + path: '/api/v2/yfm/boards', + commands: ['resource create'], + }, + resource_api_v2_board_update: { + method: 'put', + path: '/api/v2/yfm/boards', + commands: ['resource update'], + }, }; export function loadSpec(): OpenApiDocument {