Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c5f1faeb1 | ||
|
|
5833e71751 | ||
|
|
0e690fbe4e | ||
|
|
f7e8106a72 | ||
|
|
87a9b5b9be | ||
|
|
97678e6441 | ||
|
|
67090ae09f | ||
|
|
6f042f9167 | ||
|
|
78dd4aaa4b | ||
|
|
088a4d67ae | ||
|
|
81f5245c8a | ||
|
|
c4946c3eaf |
@@ -1,103 +0,0 @@
|
||||
name: Auto Dev Release
|
||||
|
||||
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
|
||||
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
|
||||
#
|
||||
# 设计要点:
|
||||
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
|
||||
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
|
||||
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
|
||||
# - 不含 npm publish,所以自动发布天然是绿的。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/dws-devapp
|
||||
|
||||
# 串行:两次 push 不会同时算版本号撞车
|
||||
concurrency:
|
||||
group: auto-dev-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
# ---- 质量门:测试不过就不发 ----
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Test
|
||||
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
# ---- 算下一个 dev 版本号并打 tag ----
|
||||
- name: Compute next dev version
|
||||
id: ver
|
||||
run: |
|
||||
set -eu
|
||||
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
|
||||
if [ -z "$LATEST" ]; then
|
||||
BASE="v1.0.39"; N=0
|
||||
else
|
||||
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
|
||||
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
|
||||
fi
|
||||
NEW="${BASE}-dev.$((N + 1))"
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "next dev release: $NEW (prev: ${LATEST:-none})"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
set -eu
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$V"
|
||||
git push origin "$V"
|
||||
|
||||
# ---- 自己发 release(不靠 tag 触发 release.yml)----
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
|
||||
@@ -31,14 +31,45 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee
|
||||
- name: Push main + tags to Gitee (with README localization)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub)
|
||||
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
git checkout -B gitee-main origin/main
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
|
||||
done
|
||||
|
||||
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
|
||||
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
|
||||
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
|
||||
SVG=".github/badges/coverage.svg"
|
||||
if [ -f "$SVG" ]; then
|
||||
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
|
||||
NUM="${PCT%\%}"; INT="${NUM%.*}"
|
||||
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
|
||||
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -12,7 +12,10 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
@@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
|
||||
|
||||
## [1.0.43] - 2026-06-26
|
||||
|
||||
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
|
||||
|
||||
### Added
|
||||
|
||||
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
|
||||
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
|
||||
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
|
||||
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
|
||||
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
|
||||
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
|
||||
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
|
||||
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
|
||||
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
|
||||
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
|
||||
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
|
||||
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
|
||||
|
||||
## [1.0.42] - 2026-06-25
|
||||
|
||||
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
|
||||
|
||||
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
**3. Skills only (Gitee mirror):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -309,6 +317,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
**Switching or re-installing with `dws skill setup`:**
|
||||
|
||||
@@ -518,25 +528,25 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
|
||||
| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Calendar | `calendar` | 23 | `event` `book` `acl` `attendee` `participant` `room` `busy` `attachment` | Events CRUD + suggested times + attachments, calendar books (get / search / primary via `--id primary`), access-control list, attendee management (wukong-aligned naming, `--calendar-id` aware), meeting room booking, free-busy query |
|
||||
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
|
||||
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, operation records |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
|
||||
| Report | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
|
||||
| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); datasheet forms; templates |
|
||||
| Doc | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| AI Tables | `aitable` | 102 | `base` `table` `record` `field` `view` `section` `advperm` `workflow` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; node (section) management, advanced permission & roles, automation workflows; record upsert / share-url / history / primary-doc; view lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar; charts & dashboards; import/export; attachments; forms; templates |
|
||||
| Doc | `doc` | 29 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` `permission` `media` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), permission management, media, upload / download |
|
||||
| Drive | `drive` | 17 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` `copy` `move` `rename` `search` `permission` | DingTalk drive file ops: list / search / info / download, create folders, upload (one-shot or two-phase), copy / move / rename, permission management (list / mutation / remove), doc-transfer, delete |
|
||||
| Minutes | `minutes` | 31 | `list` `get` `tag` `update` `mind-graph` `speaker` `permission` `hot-word` `record` `upload` `replace-text` | List AI meeting notes (mine / shared / all), tags (list / query by tag), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker, member permission, hot-word, recording control, upload session |
|
||||
| Mail | `mail` | 33 | `mailbox` `message` `send` `draft` `folder` `template` `contact` `tag` `thread` `attachment` `user` | List mailboxes, KQL search + folder-scoped message list, read & send messages, drafts, folder CRUD, message templates (CRUD), mail contacts (CRUD), tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 60 | `range` `filter` `filter-view` `cond-format` (+ dimension, float-image, dropdown, csv, merge, find/replace, write-image, …) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / copy / fill / sort / style, dimension ops, filters & filter views, conditional formatting, float images, dropdowns, CSV get/put, cell merge / unmerge, find / replace, image write |
|
||||
| Wiki | `wiki` | 27 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (create / get / list / search / delete), members (add / list / update / remove), node tree (create / delete / list / search / move / copy / transfer), docs & files |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **466 commands across 18 products** (after the dws-wukong alignment in 1.0.43). Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
|
||||
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
**3. 单独安装 Skills(Gitee 镜像):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -306,6 +314,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
**用 `dws skill setup` 切换或重装:**
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DevApp 一键安装与 Agent 接入指南
|
||||
# dws dev 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
@@ -6,45 +6,44 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
### Windows(PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
|
||||
2. 使用本地源码构建 `dws`。
|
||||
3. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
|
||||
|
||||
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell 正式安装命令:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
安装脚本支持这些环境变量:
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
|
||||
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
|
||||
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
|
||||
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
|
||||
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
|
||||
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
|
||||
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
|
||||
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
### 国内加速
|
||||
|
||||
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
## 安装后验证
|
||||
|
||||
@@ -54,13 +53,13 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 DevApp 命令存在:
|
||||
确认 `dws dev app` 命令存在:
|
||||
|
||||
```bash
|
||||
dws devapp --help --format json
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
|
||||
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
@@ -77,14 +76,18 @@ dws auth login
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws dev app list --format json
|
||||
```
|
||||
|
||||
## DevApp 是什么
|
||||
## dws dev 是什么
|
||||
|
||||
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
|
||||
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
|
||||
|
||||
它让 Agent 可以完成这些工作:
|
||||
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
|
||||
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
|
||||
- `dws dev doc` — 开放平台开发文档搜索。
|
||||
|
||||
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
@@ -92,12 +95,12 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 创建、查询、更新、启用、停用机器人。
|
||||
- 异步创建机器人、配置/启停现有机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 DevApp。
|
||||
安装完成后,可以直接让 Agent 操作 `dws dev`。
|
||||
|
||||
示例:
|
||||
|
||||
@@ -114,7 +117,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先检查发布前置条件。
|
||||
帮我发布这个应用版本,先预检是否需要审批。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
@@ -127,34 +130,30 @@ Agent 写操作必须遵循:
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。先 dry-run:
|
||||
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
|
||||
|
||||
提交建号任务(记下返回的 `taskId`):
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
dws dev app robot submit \
|
||||
--name "告警助手" \
|
||||
--robot-name "告警机器人" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后执行:
|
||||
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--yes \
|
||||
--format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
```
|
||||
|
||||
回读验证:
|
||||
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
@@ -162,19 +161,21 @@ dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws devapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp create --name "考勤应用" --dry-run --format json
|
||||
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app list --format json
|
||||
dws dev app get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app create --name "考勤应用" --dry-run --format json
|
||||
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
|
||||
```
|
||||
|
||||
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
@@ -182,68 +183,82 @@ dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws devapp permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人配置
|
||||
### 机器人能力
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws devapp member list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
dws dev app member list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 网页应用与事件
|
||||
|
||||
```bash
|
||||
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
|
||||
dws dev app event list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
|
||||
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认。
|
||||
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
|
||||
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws devapp` 不存在
|
||||
### `dws dev app` 不存在
|
||||
|
||||
先确认安装的是预览分支源码,而不是正式 Release:
|
||||
先确认装上的是带 `dws dev` 的版本:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws devapp --help --format json
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
|
||||
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
|
||||
|
||||
### `dws devapp list` 失败
|
||||
### `dws dev app list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
@@ -254,11 +269,13 @@ dws auth login
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 提示"当前用户没有开发者身份"
|
||||
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
|
||||
|
||||
先确认当前用户是否满足以下任一条件:
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
@@ -270,18 +287,17 @@ dws auth login
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,使用 `robot config` 创建或配置。
|
||||
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
|
||||
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install DevApp in one command.
|
||||
Install dws dev in one command.
|
||||
|
||||
Let your coding agents manage DingTalk Open Platform apps from the terminal:
|
||||
create apps, configure robots, apply permissions, manage security settings,
|
||||
@@ -291,7 +307,7 @@ and publish versions with dry-run safety built in.
|
||||
中文版本:
|
||||
|
||||
```text
|
||||
一行命令接入 DevApp。
|
||||
一行命令接入 dws dev。
|
||||
|
||||
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
|
||||
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,14 @@
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
> ```bash
|
||||
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
> ```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
@@ -23,12 +28,12 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
|
||||
type toolMappingParam struct {
|
||||
Flag string `json:"flag"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
|
||||
type toolMappingEntry struct {
|
||||
Product string `json:"product"`
|
||||
CLICommand string `json:"cliCommand"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Params map[string]toolMappingParam `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
|
||||
type toolMapping struct {
|
||||
Version string `json:"version"`
|
||||
Count int `json:"count"`
|
||||
Tools map[string]toolMappingEntry `json:"tools"`
|
||||
}
|
||||
|
||||
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
|
||||
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
|
||||
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
|
||||
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
catalogCmd := &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "导出已发现的工具目录(内部用)",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
var out string
|
||||
var version string
|
||||
exportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
catalog, err := loader.Load(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mapping := projectToolMapping(catalog, version)
|
||||
data, err := json.MarshalIndent(mapping, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if strings.TrimSpace(out) == "" {
|
||||
_, werr := os.Stdout.Write(data)
|
||||
return werr
|
||||
}
|
||||
return os.WriteFile(out, data, 0o644)
|
||||
},
|
||||
}
|
||||
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
|
||||
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
|
||||
|
||||
catalogCmd.AddCommand(exportCmd)
|
||||
return catalogCmd
|
||||
}
|
||||
|
||||
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
|
||||
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
|
||||
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
|
||||
for _, product := range catalog.Products {
|
||||
command := ""
|
||||
if product.CLI != nil {
|
||||
command = strings.TrimSpace(product.CLI.Command)
|
||||
}
|
||||
if command == "" {
|
||||
command = product.ID
|
||||
}
|
||||
for _, tool := range product.Tools {
|
||||
if tool.Hidden {
|
||||
continue
|
||||
}
|
||||
entry := toolMappingEntry{
|
||||
Product: command,
|
||||
CLICommand: tmBuildCLICommand(command, tool),
|
||||
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
|
||||
Params: make(map[string]toolMappingParam),
|
||||
}
|
||||
for name, raw := range tmSchemaProperties(tool.InputSchema) {
|
||||
prop, _ := raw.(map[string]any)
|
||||
overlay, hasOverlay := tool.FlagOverlay[name]
|
||||
if hasOverlay && overlay.Hidden {
|
||||
continue
|
||||
}
|
||||
flag := tmKebab(name)
|
||||
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
|
||||
flag = strings.TrimSpace(overlay.Alias)
|
||||
}
|
||||
label := tmMapStr(prop, "title")
|
||||
if label == "" {
|
||||
label = tmFirstLine(tmMapStr(prop, "description"))
|
||||
}
|
||||
entry.Params[name] = toolMappingParam{
|
||||
Flag: flag,
|
||||
Label: label,
|
||||
Type: tmMapStr(prop, "type"),
|
||||
}
|
||||
}
|
||||
if len(entry.Params) == 0 {
|
||||
entry.Params = nil
|
||||
}
|
||||
mapping.Tools[tool.RPCName] = entry
|
||||
}
|
||||
}
|
||||
mapping.Count = len(mapping.Tools)
|
||||
return mapping
|
||||
}
|
||||
|
||||
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
|
||||
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if command != "" {
|
||||
parts = append(parts, command)
|
||||
}
|
||||
if g := strings.TrimSpace(tool.Group); g != "" {
|
||||
parts = append(parts, g)
|
||||
}
|
||||
name := strings.TrimSpace(tool.CLIName)
|
||||
if name == "" {
|
||||
name = tool.RPCName
|
||||
}
|
||||
parts = append(parts, name)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func tmSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
return props
|
||||
}
|
||||
|
||||
func tmMapStr(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := m[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
|
||||
func tmFirstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexAny(s, "\n。"); i >= 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tmFirstNonEmpty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return strings.TrimSpace(a)
|
||||
}
|
||||
return strings.TrimSpace(b)
|
||||
}
|
||||
|
||||
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
|
||||
func tmKebab(s string) string {
|
||||
var b strings.Builder
|
||||
for i, r := range s {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r - 'A' + 'a')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -55,9 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
// `minutes list all` is intentionally provided as a hardcoded helper
|
||||
// (see internal/helpers/minutes_commands.go) to align with the wukong
|
||||
// baseline, so it is expected to resolve and is no longer asserted here.
|
||||
for _, path := range []string{
|
||||
"chat message list-topic-replies",
|
||||
"minutes list all",
|
||||
} {
|
||||
if cmd := lookupCommand(root, path); cmd != nil {
|
||||
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
|
||||
|
||||
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
case r.URL.Path == "/cli/discovery/apis/cedar":
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
case r.URL.Path == "/cli/discovery/apis/cedar":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
|
||||
@@ -96,6 +96,7 @@ func Execute() (exitCode int) {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
@@ -114,6 +115,36 @@ func isUnknownCommandError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "unknown command")
|
||||
}
|
||||
|
||||
// rewordRequiredFlagError rewrites cobra's default missing-required-flag message
|
||||
// (`required flag(s) "email" not set`) into the wukong-aligned form
|
||||
// (`missing required flag(s): --email`). cobra's ValidateRequiredFlags returns
|
||||
// this error directly (it does not pass through FlagErrorFunc), so it is
|
||||
// normalised here. The substring "required flag" is preserved for compatibility
|
||||
// with existing assertions; flag names gain the "--" prefix and quotes are
|
||||
// dropped so error output matches hardcoded cmdutil.ValidateRequiredFlags.
|
||||
func rewordRequiredFlagError(err error) error {
|
||||
if err == nil {
|
||||
return err
|
||||
}
|
||||
const pfx = "required flag(s) "
|
||||
const sfx = " not set"
|
||||
msg := err.Error()
|
||||
if !strings.HasPrefix(msg, pfx) || !strings.HasSuffix(msg, sfx) {
|
||||
return err
|
||||
}
|
||||
mid := strings.TrimSuffix(strings.TrimPrefix(msg, pfx), sfx)
|
||||
var flags []string
|
||||
for _, part := range strings.Split(mid, ", ") {
|
||||
if name := strings.Trim(strings.TrimSpace(part), "\""); name != "" {
|
||||
flags = append(flags, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return err
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("missing required flag(s): %s", strings.Join(flags, ", ")))
|
||||
}
|
||||
|
||||
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
|
||||
//
|
||||
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
|
||||
@@ -330,6 +361,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newCatalogCommand(loader),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newCompletionCommand(root),
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
case "/cli/discovery/apis/cedar":
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 2},
|
||||
Servers: []market.ServerEnvelope{
|
||||
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
case "/cli/discovery/apis/cedar":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -333,6 +334,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
|
||||
// surface advertises the resolved MCP arguments without polluting the
|
||||
// stdout payload (which stays valid JSON in --format json mode). Mirrors
|
||||
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
|
||||
// machine-readable channel.
|
||||
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
@@ -492,6 +501,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
// Align with wukong's response envelope: stamp a top-level success=true on
|
||||
// map payloads that don't already carry a success flag. Business errors
|
||||
// (success=false) are intercepted above, so reaching here means the call
|
||||
// succeeded. Additive only — existing keys are never overwritten.
|
||||
if callResult.Content != nil {
|
||||
if _, has := callResult.Content["success"]; !has {
|
||||
callResult.Content["success"] = true
|
||||
}
|
||||
}
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": callResult.Content,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// attendanceScheduleInnerRequired are the fields every scheduleVOS item must
|
||||
// carry; the backend rejects partial items with an opaque error, so the CLI
|
||||
// validates them up front (mirrors wukong's attendance.go).
|
||||
var attendanceScheduleInnerRequired = []string{"userId", "workDate", "classId", "isRest"}
|
||||
|
||||
var attendanceGroupTypes = map[string]bool{"FIXED": true, "TURN": true, "NONE": true}
|
||||
|
||||
var attendanceApproveTypes = map[string]bool{
|
||||
"overtime": true, "trip": true, "travel": true, "business_trip": true,
|
||||
"business-trip": true, "out": true, "leave": true, "patch": true,
|
||||
"repair_check": true, "repair-check": true,
|
||||
}
|
||||
|
||||
// installAttendanceHook wires attendance-specific PreRunE validators that
|
||||
// mirror wukong's client-side checks (inner-JSON required fields, group type,
|
||||
// FIXED conditional requirements, group-update no-op). No-op for other
|
||||
// products / tools. Preserves any PreRunE NewDirectCommand already installed.
|
||||
func installAttendanceHook(cmd *cobra.Command, canonicalProduct, toolName string) {
|
||||
if cmd == nil || strings.TrimSpace(canonicalProduct) != "attendance" {
|
||||
return
|
||||
}
|
||||
var validate func(*cobra.Command) error
|
||||
switch toolName {
|
||||
case "generateTurnSchedule":
|
||||
validate = validateAttendanceScheduleImport
|
||||
case "create_class_setting":
|
||||
validate = validateAttendanceClassCreate
|
||||
case "create_group_setting":
|
||||
validate = validateAttendanceGroupCreate
|
||||
case "update_group_setting":
|
||||
validate = validateAttendanceGroupUpdate
|
||||
case "update_group_member":
|
||||
validate = validateAttendanceUpdateMembers
|
||||
case "save_self_setting":
|
||||
validate = validateAttendanceSelfSettingSave
|
||||
case "query_at_approve_template":
|
||||
validate = validateAttendanceApproveTemplates
|
||||
default:
|
||||
return
|
||||
}
|
||||
original := cmd.PreRunE
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if original != nil {
|
||||
if err := original(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return validate(c)
|
||||
}
|
||||
}
|
||||
|
||||
func attFlagString(cmd *cobra.Command, names ...string) string {
|
||||
for _, n := range names {
|
||||
if cmd.Flags().Lookup(n) == nil {
|
||||
continue
|
||||
}
|
||||
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateAttendanceScheduleImport(cmd *cobra.Command) error {
|
||||
raw := attFlagString(cmd, "scheduleVOS", "schedules")
|
||||
if raw == "" {
|
||||
return nil // empty is owned by the required-flag check
|
||||
}
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil // malformed JSON is owned by a separate check
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return apperrors.NewValidation("--scheduleVOS requires at least one schedule entry (empty array not allowed)")
|
||||
}
|
||||
for _, item := range items {
|
||||
for _, f := range attendanceScheduleInnerRequired {
|
||||
if _, ok := item[f]; !ok {
|
||||
return apperrors.NewValidation("missing required field: " + f + "(--scheduleVOS 每个排班项必填)")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttendanceClassCreate(cmd *cobra.Command) error {
|
||||
raw := attFlagString(cmd, "class-vo", "TopAtClassVO")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var vo map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &vo); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := vo["sections"]; !ok {
|
||||
return apperrors.NewValidation("missing required field: sections(班次时段,--class-vo 内必填)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttendanceGroupCreate(cmd *cobra.Command) error {
|
||||
typ := strings.TrimSpace(attFlagString(cmd, "type"))
|
||||
if typ != "" && !attendanceGroupTypes[typ] {
|
||||
return apperrors.NewValidation("考勤组类型不合法:--type 应为 FIXED / TURN / NONE 之一")
|
||||
}
|
||||
if typ == "FIXED" {
|
||||
var vo map[string]any
|
||||
if raw := attFlagString(cmd, "group-vo", "groupVO"); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &vo)
|
||||
}
|
||||
if vo == nil {
|
||||
vo = map[string]any{}
|
||||
}
|
||||
if _, ok := vo["workDayClassList"]; !ok {
|
||||
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 workDayClassList(工作日班次列表)")
|
||||
}
|
||||
if _, ok := vo["defaultClassId"]; !ok {
|
||||
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 defaultClassId(默认班次 ID)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttendanceGroupUpdate(cmd *cobra.Command) error {
|
||||
if v := strings.TrimSpace(attFlagString(cmd, "enable-outside-check")); v != "" && v != "true" && v != "false" {
|
||||
return apperrors.NewValidation("--enable-outside-check must be true or false")
|
||||
}
|
||||
for _, f := range []string{"name", "type", "owner", "enable-outside-check", "classIds", "group-vo"} {
|
||||
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.NewValidation("至少需要指定一个修改项(--name / --type / --owner / --enable-outside-check / --classIds / --group-vo)")
|
||||
}
|
||||
|
||||
func validateAttendanceUpdateMembers(cmd *cobra.Command) error {
|
||||
for _, f := range []string{"add-users", "remove-users", "add-extra-users", "remove-extra-users", "add-depts", "remove-depts"} {
|
||||
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.NewValidation("至少需要指定一个变更项(--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts)")
|
||||
}
|
||||
|
||||
func validateAttendanceSelfSettingSave(cmd *cobra.Command) error {
|
||||
hasField := false
|
||||
cmd.Flags().Visit(func(f *pflag.Flag) {
|
||||
switch f.Name {
|
||||
case "setting-scene", "user", "yes", "format", "debug", "verbose", "dry-run",
|
||||
"client-id", "client-secret", "fields", "jq", "mock", "timeout":
|
||||
// control / identity flags, not setting fields
|
||||
default:
|
||||
hasField = true
|
||||
}
|
||||
})
|
||||
if !hasField {
|
||||
return apperrors.NewValidation("至少需要指定一个设置项(--setting-scene / --user 之外的任一字段)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttendanceApproveTemplates(cmd *cobra.Command) error {
|
||||
typ := strings.TrimSpace(attFlagString(cmd, "type"))
|
||||
if typ != "" && !attendanceApproveTypes[typ] {
|
||||
return apperrors.NewValidation("无效的审批类型:--type 应为 overtime / leave / patch / trip(travel) / business_trip 之一")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// calendarRecurrenceTools are the calendar leaves whose recurrence fields must
|
||||
// be supplied as a complete set (the MCP backend does not merge partial
|
||||
// recurrence, so a partial update would silently overwrite the rule). Mirrors
|
||||
// wukong's calendar.go event create/update validation.
|
||||
var calendarRecurrenceTools = map[string]bool{
|
||||
"create_calendar_event": true,
|
||||
"update_calendar_event": true,
|
||||
}
|
||||
|
||||
// calendarRecurrenceFlags is the full set of --recurrence-* flags; touching any
|
||||
// of them requires the core structural fields to be present.
|
||||
var calendarRecurrenceFlags = []string{
|
||||
"recurrence-type", "recurrence-interval", "recurrence-range-type",
|
||||
"recurrence-count", "recurrence-end-date", "recurrence-days-of-week",
|
||||
"recurrence-day-of-month", "recurrence-month", "recurrence-week-index",
|
||||
"recurrence-first-day-of-week",
|
||||
}
|
||||
|
||||
// installCalendarHook wires calendar-specific PreRunE validators onto leaf
|
||||
// commands emitted by BuildDynamicCommands. No-op for non-calendar products and
|
||||
// calendar tools without extra client-side checks. The hook chain preserves the
|
||||
// PreRunE that NewDirectCommand already installed by invoking it first.
|
||||
func installCalendarHook(cmd *cobra.Command, canonicalProduct, toolName string) {
|
||||
if cmd == nil || strings.TrimSpace(canonicalProduct) != "calendar" {
|
||||
return
|
||||
}
|
||||
if !calendarRecurrenceTools[toolName] {
|
||||
return
|
||||
}
|
||||
original := cmd.PreRunE
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if original != nil {
|
||||
if err := original(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return validateCalendarRecurrence(c)
|
||||
}
|
||||
}
|
||||
|
||||
// validateCalendarRecurrence refuses a partial recurrence structure. If any
|
||||
// --recurrence-* flag is set, recurrence-type / interval / range-type must be
|
||||
// present, and weekly / relativeMonthly patterns require days-of-week. Error
|
||||
// wording carries the kebab flag names so the messages match wukong and the
|
||||
// auto-test substring assertions (days-of-week / recurrence-type).
|
||||
func validateCalendarRecurrence(cmd *cobra.Command) error {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
used := false
|
||||
for _, f := range calendarRecurrenceFlags {
|
||||
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
|
||||
used = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !used {
|
||||
return nil
|
||||
}
|
||||
|
||||
recType := strings.TrimSpace(calendarFlagString(cmd, "recurrence-type"))
|
||||
if recType == "" {
|
||||
return apperrors.NewValidation(
|
||||
"recurrence 结构不完整:使用任一 --recurrence-* 时必须整体重传完整循环字段" +
|
||||
"(至少 --recurrence-type / --recurrence-interval / --recurrence-range-type," +
|
||||
"MCP 不合并部分字段)")
|
||||
}
|
||||
if !calendarFlagSet(cmd, "recurrence-interval") {
|
||||
return apperrors.NewValidation(
|
||||
"recurrence 结构不完整:缺少 --recurrence-interval(循环间隔,recurrence 整体必填)")
|
||||
}
|
||||
if !calendarFlagSet(cmd, "recurrence-range-type") {
|
||||
return apperrors.NewValidation(
|
||||
"recurrence 结构不完整:缺少 --recurrence-range-type(循环范围类型,recurrence 整体必填)")
|
||||
}
|
||||
if recType == "weekly" || recType == "relativeMonthly" {
|
||||
if strings.TrimSpace(calendarFlagString(cmd, "recurrence-days-of-week")) == "" {
|
||||
return apperrors.NewValidation(
|
||||
"weekly / relativeMonthly 循环必须提供 --recurrence-days-of-week (daysOfWeek)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calendarFlagString(cmd *cobra.Command, name string) string {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// calendarFlagSet reports whether a flag was explicitly provided by the user,
|
||||
// tolerating both string and int (--recurrence-interval) flag kinds.
|
||||
func calendarFlagSet(cmd *cobra.Command, name string) bool {
|
||||
fl := cmd.Flags().Lookup(name)
|
||||
if fl == nil {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().Changed(name)
|
||||
}
|
||||
@@ -239,6 +239,15 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
// todo_hooks.go for the full rationale). No-op for non-todo.
|
||||
installTodoHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §calendar-hook: reject partial recurrence on event create/update
|
||||
// (see calendar_hooks.go). No-op for non-calendar.
|
||||
installCalendarHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §attendance-hook: inner-JSON required fields, group type and
|
||||
// FIXED conditional checks (see attendance_hooks.go). No-op for
|
||||
// non-attendance.
|
||||
installAttendanceHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §1.4: Add to the right parent group
|
||||
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
|
||||
}
|
||||
|
||||
@@ -336,7 +336,16 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
Response: resp,
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
// Shape the MCP content payload (the actual data), not the
|
||||
// {endpoint, content} runtime envelope, so rename/drop/table
|
||||
// paths resolve against response fields (e.g. result.items)
|
||||
// rather than the wrapper. Falls back to the whole Response
|
||||
// when no content map is present (degraded/echo paths).
|
||||
if content, ok := result.Response["content"].(map[string]any); ok {
|
||||
result.Response["content"] = route.OutputTransform(content)
|
||||
} else {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
}
|
||||
@@ -862,7 +871,7 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
|
||||
if _, ok := existing[binding.Property]; ok {
|
||||
continue
|
||||
}
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("missing required flag: --%s is required", primaryName))
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map, file_read, invert_bool, string_to_int64.
|
||||
// json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -49,6 +49,10 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformFileRead(value)
|
||||
case "invert_bool":
|
||||
return transformInvertBool(value)
|
||||
case "parse_bool":
|
||||
return transformParseBool(value)
|
||||
case "attendance_class_check_time":
|
||||
return transformAttendanceClassCheckTime(value)
|
||||
case "string_to_int64":
|
||||
return transformStringToInt64(value)
|
||||
default:
|
||||
@@ -79,6 +83,77 @@ func transformInvertBool(value any) (any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// transformParseBool coerces a CLI string flag into a real JSON boolean so the
|
||||
// MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed
|
||||
// zero value). Used by envelope flags that are semantically boolean but must be
|
||||
// declared as string flags to accept an explicit `false` on the command line
|
||||
// (cobra bool flags drop the space-form value). Unknown tokens pass through
|
||||
// unchanged so upstream validators own the error wording.
|
||||
func transformParseBool(value any) (any, error) {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v, nil
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true, nil
|
||||
case "false", "0", "no", "off":
|
||||
return false, nil
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// transformAttendanceClassCheckTime parses a class-VO JSON string and converts
|
||||
// every "HH:mm" checkTime under sections[*].times[*] and
|
||||
// setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in
|
||||
// UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the
|
||||
// numeric form; the envelope cannot express this nested walk, so it lives here.
|
||||
func transformAttendanceClassCheckTime(value any) (any, error) {
|
||||
parsed, err := transformJSONParseStrict(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
classVO, ok := parsed.(map[string]any)
|
||||
if !ok {
|
||||
return parsed, nil
|
||||
}
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
convertOne := func(obj map[string]any) {
|
||||
if ct, ok := obj["checkTime"].(string); ok {
|
||||
ct = strings.TrimSpace(ct)
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil {
|
||||
obj["checkTime"] = float64(t.UnixMilli())
|
||||
}
|
||||
}
|
||||
}
|
||||
if sections, ok := classVO["sections"].([]any); ok {
|
||||
for _, sec := range sections {
|
||||
if secMap, ok := sec.(map[string]any); ok {
|
||||
if times, ok := secMap["times"].([]any); ok {
|
||||
for _, t := range times {
|
||||
if tMap, ok := t.(map[string]any); ok {
|
||||
convertOne(tMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if setting, ok := classVO["setting"].(map[string]any); ok {
|
||||
if restList, ok := setting["topRestTimeList"].([]any); ok {
|
||||
for _, item := range restList {
|
||||
if itemMap, ok := item.(map[string]any); ok {
|
||||
convertOne(itemMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return classVO, nil
|
||||
}
|
||||
|
||||
func transformISO8601ToMillis(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
// newTestMCPServer returns an httptest.Server that handles both market registry
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
|
||||
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
|
||||
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !marketOK {
|
||||
http.Error(w, "market unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
||||
@@ -110,6 +110,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newAitableFieldCreateCommand(runner),
|
||||
newAitableFieldUpdateCommand(runner),
|
||||
newAitableFieldDeleteCommand(runner),
|
||||
newAitableFieldSearchOptionsCommand(runner),
|
||||
newAitableFieldListAlias(runner),
|
||||
)
|
||||
|
||||
@@ -129,7 +130,13 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newAitableRecordCreateCommand(runner),
|
||||
newAitableRecordUpdateCommand(runner),
|
||||
newAitableRecordBatchUpdateCommand(runner),
|
||||
newAitableRecordQueryEmptyCommand(runner),
|
||||
newAitableRecordDeleteCommand(runner),
|
||||
newAitableRecordHistoryListCommand(runner),
|
||||
newAitableRecordShareURLCommand(runner),
|
||||
newAitableRecordUpsertCommand(runner),
|
||||
newAitableRecordPrimaryDocGetCommand(runner),
|
||||
newAitableRecordPrimaryDocCreateCommand(runner),
|
||||
newAitableRecordListAlias(runner),
|
||||
)
|
||||
|
||||
@@ -251,6 +258,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newAitableDashboardUpdateCommand(runner),
|
||||
newAitableDashboardDeleteCommand(runner),
|
||||
newAitableDashboardConfigExampleCommand(runner),
|
||||
newAitableDashboardArrangeCommand(runner),
|
||||
dashboardShare,
|
||||
)
|
||||
|
||||
@@ -264,15 +272,53 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
viewGet := newAitableViewGetCommand(runner)
|
||||
viewGet.AddCommand(
|
||||
newAitableViewGetCardCommand(runner),
|
||||
newAitableViewGetTimebarCommand(runner),
|
||||
newAitableViewGetAggregateCommand(runner),
|
||||
newAitableViewGetFilterCommand(runner),
|
||||
newAitableViewGetSortCommand(runner),
|
||||
newAitableViewGetGroupCommand(runner),
|
||||
newAitableViewGetVisibleFieldsCommand(runner),
|
||||
newAitableViewGetFieldWidthsCommand(runner),
|
||||
newAitableViewGetLockCommand(runner),
|
||||
newAitableViewGetFrozenColsCommand(runner),
|
||||
newAitableViewGetRowHeightCommand(runner),
|
||||
newAitableViewGetFillColorRuleCommand(runner),
|
||||
)
|
||||
viewUpdate := newAitableViewUpdateCommand(runner)
|
||||
viewUpdate.AddCommand(
|
||||
newAitableViewUpdateCardCommand(runner),
|
||||
newAitableViewUpdateTimebarCommand(runner),
|
||||
newAitableViewUpdateAggregateCommand(runner),
|
||||
newAitableViewUpdateFieldWidthsCommand(runner),
|
||||
newAitableViewUpdateVisibleFieldsCommand(runner),
|
||||
newAitableViewUpdateFilterCommand(runner),
|
||||
newAitableViewUpdateSortCommand(runner),
|
||||
newAitableViewUpdateGroupCommand(runner),
|
||||
newAitableViewUpdateNameCommand(runner),
|
||||
newAitableViewUpdateFrozenColsCommand(runner),
|
||||
newAitableViewUpdateRowHeightCommand(runner),
|
||||
newAitableViewUpdateFillColorRuleCommand(runner),
|
||||
)
|
||||
view.AddCommand(
|
||||
newAitableViewGetCommand(runner),
|
||||
viewGet,
|
||||
newAitableViewListCommand(runner),
|
||||
newAitableViewCreateCommand(runner),
|
||||
newAitableViewUpdateCommand(runner),
|
||||
viewUpdate,
|
||||
newAitableViewDeleteCommand(runner),
|
||||
newAitableViewLockCommand(runner),
|
||||
newAitableViewDuplicateCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(base, table, field, record, newAitableFormCommand(runner), template, attachment, export, importCmd, dashboard, chart, view)
|
||||
root.AddCommand(
|
||||
base, table, field, record, newAitableFormCommand(runner),
|
||||
newAitableWorkflowCommand(runner),
|
||||
template, attachment, export, importCmd, dashboard, chart, view,
|
||||
newAitableAdvpermCommand(runner),
|
||||
newAitableSectionCommand(runner),
|
||||
)
|
||||
|
||||
// 顶层别名:dws aitable search/list/create/info → base search/list/create/get
|
||||
// 每个 alias 复用现有 constructor,独立 cobra.Command 实例(避免与 base.* 共享 flag 指针)
|
||||
|
||||
@@ -130,7 +130,33 @@ func newAitableBaseCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
if folderID := aitableStringFlag(cmd, "folder-id"); folderID != "" {
|
||||
params["folderId"] = folderID
|
||||
}
|
||||
return runAitableTool(cmd, runner, "create_base", params)
|
||||
// dry-run 或带模板:保持单步 create_base 语义。
|
||||
if commandDryRun(cmd) {
|
||||
return runAitableTool(cmd, runner, "create_base", params)
|
||||
}
|
||||
result, err := runAitableProductToolResult(cmd, runner, "aitable", "create_base", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 默认表由服务端按 scenario 决定:wukong 场景建、openClaw 不建。
|
||||
// 为与 wukong 行为对齐,无模板时在 CLI 侧兜底补建一张默认表,
|
||||
// 使新 base 立即可用(含 tableId)。输出仍保持原 create_base 结果,
|
||||
// 不把内部补建步骤暴露给调用方。
|
||||
if _, hasTemplate := params["templateId"]; !hasTemplate {
|
||||
if baseID := findStringDeep(result.Response, "baseId", "baseID"); baseID != "" {
|
||||
if _, terr := runAitableProductToolResult(cmd, runner, "aitable", "create_table", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableName": "表格1",
|
||||
"fields": []any{map[string]any{
|
||||
"fieldName": "标题",
|
||||
"type": "text",
|
||||
}},
|
||||
}); terr != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: created base %s but default table creation failed: %v\n", baseID, terr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
@@ -1440,6 +1466,9 @@ func newAitableViewUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeAitableViewConfigBlock(cmd, config); err != nil {
|
||||
return err
|
||||
}
|
||||
params["config"] = config
|
||||
}
|
||||
if _, hasName := params["newViewName"]; !hasName {
|
||||
@@ -1499,6 +1528,14 @@ func runAitableFormTool(cmd *cobra.Command, runner executor.Runner, tool string,
|
||||
}
|
||||
|
||||
func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
|
||||
result, err := runAitableProductToolResult(cmd, runner, product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func runAitableProductToolResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
product,
|
||||
@@ -1509,9 +1546,9 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
|
||||
if invocation.DryRun {
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
return executor.Result{}, err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
@@ -1523,7 +1560,7 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
timer.Stop()
|
||||
return cmd.Context().Err()
|
||||
return executor.Result{}, cmd.Context().Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
@@ -1531,16 +1568,16 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
lastErr = err
|
||||
if err == nil {
|
||||
return writeCommandPayload(cmd, result)
|
||||
return result, nil
|
||||
}
|
||||
if !aitableErrorRetryable(err) {
|
||||
return err
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
return executor.Result{}, lastErr
|
||||
}
|
||||
return nil
|
||||
return executor.Result{}, nil
|
||||
}
|
||||
|
||||
const aitableHelperMaxRetries = 3
|
||||
@@ -1906,11 +1943,15 @@ func normalizeAitableSort(items []any) []any {
|
||||
}
|
||||
|
||||
func parseAitableJSONArray(raw, flagName string) ([]any, error) {
|
||||
var value []any
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
|
||||
}
|
||||
return value, nil
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON array / 数组, got %T", flagName, value))
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func parseAitableJSONObject(raw, flagName string) (map[string]any, error) {
|
||||
|
||||
@@ -1393,3 +1393,55 @@ func singleAitableRecord(t *testing.T, value any) map[string]any {
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// base create 无模板时,CLI 兜底补建一张默认表(与 wukong 场景行为对齐:
|
||||
// 服务端对 openClaw 不建默认表,CLI 侧补齐使新 base 立即含 tableId)。
|
||||
func TestAitableBaseCreateAddsDefaultTableWithoutTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{
|
||||
{"data": map[string]any{"baseId": "BASE_001"}},
|
||||
{"data": map[string]any{"tableId": "TABLE_001"}},
|
||||
}}
|
||||
cmd := newAitableBaseCreateCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--name", "项目跟踪"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want create_base + create_table", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].Tool; got != "create_base" {
|
||||
t.Fatalf("first tool = %q, want create_base", got)
|
||||
}
|
||||
if got := runner.calls[1].Tool; got != "create_table" {
|
||||
t.Fatalf("second tool = %q, want create_table", got)
|
||||
}
|
||||
if got := runner.calls[1].Params["baseId"]; got != "BASE_001" {
|
||||
t.Fatalf("create_table baseId = %#v, want BASE_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
// base create 带 --template-id 时不补建默认表(模板自带结构)。
|
||||
func TestAitableBaseCreateWithTemplateSkipsDefaultTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{
|
||||
{"data": map[string]any{"baseId": "BASE_001"}},
|
||||
}}
|
||||
cmd := newAitableBaseCreateCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--name", "项目跟踪", "--template-id", "TPL_001"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want only create_base", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].Tool; got != "create_base" {
|
||||
t.Fatalf("tool = %q, want create_base", got)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
type aitableSequencedRunner struct {
|
||||
calls []executor.Invocation
|
||||
responses []map[string]any
|
||||
}
|
||||
|
||||
func (r *aitableSequencedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls = append(r.calls, invocation)
|
||||
var response map[string]any
|
||||
if idx := len(r.calls) - 1; idx >= 0 && idx < len(r.responses) {
|
||||
response = r.responses[idx]
|
||||
}
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
func TestAitableFieldSearchOptionsRoutesToAitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableFieldSearchOptionsCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--field-id", "FIELD_001",
|
||||
"--keyword", "已",
|
||||
"--limit", "10",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "search_field_options" {
|
||||
t.Fatalf("Tool = %q, want search_field_options", got)
|
||||
}
|
||||
if got := runner.last.Params["keyword"]; got != "已" {
|
||||
t.Fatalf("keyword = %#v, want 已", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 10 {
|
||||
t.Fatalf("limit = %#v, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewGetFieldWidthsProjectsCustomWidthMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{{
|
||||
"content": map[string]any{
|
||||
"status": "success",
|
||||
"data": map[string]any{"views": []any{map[string]any{
|
||||
"viewId": "VIEW_001",
|
||||
"viewType": "Grid",
|
||||
"custom": map[string]any{
|
||||
"widthMap": map[string]any{"FIELD_001": 240},
|
||||
},
|
||||
}}},
|
||||
},
|
||||
}}}
|
||||
cmd := newAitableViewGetFieldWidthsCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
|
||||
}
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", payload["data"])
|
||||
}
|
||||
if got := data["FIELD_001"]; got != float64(240) {
|
||||
t.Fatalf("FIELD_001 width = %#v, want 240", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewUpdateCardDispatchesGalleryConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{
|
||||
{"content": map[string]any{
|
||||
"status": "success",
|
||||
"data": map[string]any{"views": []any{map[string]any{
|
||||
"viewId": "VIEW_001",
|
||||
"viewType": "Gallery",
|
||||
}}},
|
||||
}},
|
||||
{"content": map[string]any{"status": "success"}},
|
||||
}}
|
||||
cmd := newAitableViewUpdateCardCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--cover-mode", "custom",
|
||||
"--cover-field-id", "FIELD_001",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want 2", len(runner.calls))
|
||||
}
|
||||
update := runner.calls[1]
|
||||
if update.Tool != "update_view" {
|
||||
t.Fatalf("second tool = %q, want update_view", update.Tool)
|
||||
}
|
||||
config, ok := update.Params["config"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("config = %#v, want object", update.Params["config"])
|
||||
}
|
||||
card, ok := config["galleryCard"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("galleryCard = %#v, want object", config["galleryCard"])
|
||||
}
|
||||
if got := card["coverMode"]; got != "custom" {
|
||||
t.Fatalf("coverMode = %#v, want custom", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewUpdateConfigRoutedKeyHintsSubcommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableViewUpdateCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--config", `{"frozenColCount":2}`,
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "frozen-cols") {
|
||||
t.Fatalf("stderr missing frozen-cols hint:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordHistoryListCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--record-id", "REC_001",
|
||||
"--offset", "10",
|
||||
"--limit", "30",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "query_record_history" {
|
||||
t.Fatalf("Tool = %q, want query_record_history", got)
|
||||
}
|
||||
if got := runner.last.Params["recordId"]; got != "REC_001" {
|
||||
t.Fatalf("recordId = %#v, want REC_001", got)
|
||||
}
|
||||
if got := runner.last.Params["offset"]; got != 10 {
|
||||
t.Fatalf("offset = %#v, want 10", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 30 {
|
||||
t.Fatalf("limit = %#v, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordUpsertCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "record_upsert" {
|
||||
t.Fatalf("Tool = %q, want record_upsert", got)
|
||||
}
|
||||
records, ok := runner.last.Params["records"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("records len = %d, want 2", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("lock unlock", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableViewLockCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--off",
|
||||
)
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "lock_or_unlock_view" {
|
||||
t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
|
||||
}
|
||||
if got := runner.last.Params["action"]; got != "unlock" {
|
||||
t.Fatalf("action = %#v, want unlock", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fill color rule", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableViewUpdateFillColorRuleCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--json", `[]`,
|
||||
)
|
||||
if got := runner.last.CanonicalProduct; got != "aitable" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "set_view_fill_color_rule" {
|
||||
t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
|
||||
}
|
||||
if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
|
||||
t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableWorkflowListCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--limit", "50",
|
||||
"--offset", "100",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "list_workflows" {
|
||||
t.Fatalf("Tool = %q, want list_workflows", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 50 {
|
||||
t.Fatalf("limit = %#v, want 50", got)
|
||||
}
|
||||
if got := runner.last.Params["offset"]; got != 100 {
|
||||
t.Fatalf("offset = %#v, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordQueryEmptyRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordQueryEmptyCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--limit", "50",
|
||||
"--cursor", "CUR_001",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "query_empty_records" {
|
||||
t.Fatalf("Tool = %q, want query_empty_records", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 50 {
|
||||
t.Fatalf("limit = %#v, want 50", got)
|
||||
}
|
||||
if got := runner.last.Params["cursor"]; got != "CUR_001" {
|
||||
t.Fatalf("cursor = %#v, want CUR_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordQueryEmptyRejectsOutOfRangeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordQueryEmptyCommand(runner)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--limit", "200",
|
||||
})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for --limit 200, got nil")
|
||||
}
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("runner should not be called on invalid limit, got tool %q", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDashboardArrangeRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableDashboardArrangeCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--dashboard-id", "DASH_001",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "align_dashboard" {
|
||||
t.Fatalf("Tool = %q, want align_dashboard", got)
|
||||
}
|
||||
if got := runner.last.Params["dashboardId"]; got != "DASH_001" {
|
||||
t.Fatalf("dashboardId = %#v, want DASH_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableAdvpermRoleCreateCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--name", "市场可读",
|
||||
"--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "create_role" {
|
||||
t.Fatalf("Tool = %q, want create_role", got)
|
||||
}
|
||||
subRoles, ok := runner.last.Params["subRoles"].([]any)
|
||||
if !ok || len(subRoles) != 1 {
|
||||
t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableSectionMoveNodeCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--node-id", "NODE_001",
|
||||
"--new-parent-section-id", "",
|
||||
"--target-index", "0",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "move_nsheet_node" {
|
||||
t.Fatalf("Tool = %q, want move_nsheet_node", got)
|
||||
}
|
||||
if got := runner.last.Params["newParentSectionId"]; got != "" {
|
||||
t.Fatalf("newParentSectionId = %#v, want empty string", got)
|
||||
}
|
||||
if got := runner.last.Params["targetIndex"]; got != 0 {
|
||||
t.Fatalf("targetIndex = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler { return calendarHandler{} })
|
||||
}
|
||||
|
||||
// calendarHandler contributes the `calendar attendee list|add|delete` group.
|
||||
// wukong renamed the calendar participant commands to "attendee" (former name:
|
||||
// participant); the envelope still exposes them under "participant". These
|
||||
// leaves call the same MCP tools (get/add/remove_calendar_participant) so the
|
||||
// wukong command surface is aligned without dropping the legacy participant
|
||||
// path. MergeCommandTree folds the attendee group into the calendar tree.
|
||||
type calendarHandler struct{}
|
||||
|
||||
func (calendarHandler) Name() string { return "calendar" }
|
||||
|
||||
func (calendarHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "calendar",
|
||||
Short: i18n.T("日历"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
attendee := &cobra.Command{
|
||||
Use: "attendee",
|
||||
Short: i18n.T("参会人管理(与 participant 等价,对齐 wukong 命名)"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
attendee.AddCommand(
|
||||
newCalendarAttendeeListCommand(runner),
|
||||
newCalendarAttendeeAddCommand(runner),
|
||||
newCalendarAttendeeDeleteCommand(runner),
|
||||
)
|
||||
root.AddCommand(attendee)
|
||||
return root
|
||||
}
|
||||
|
||||
func newCalendarAttendeeListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list", Short: i18n.T("查询日程参会人"),
|
||||
Example: " dws calendar attendee list --event <eventId>", Args: cobra.NoArgs, DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --event")
|
||||
}
|
||||
params := map[string]any{"eventId": eventID}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
|
||||
params["calendarId"] = v
|
||||
}
|
||||
return runCalendarTool(cmd, runner, "get_calendar_participants", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
|
||||
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选, 默认主日历)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarAttendeeAddCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add", Short: i18n.T("添加日程参会人"),
|
||||
Example: " dws calendar attendee add --event <eventId> --users userId1,userId2", Args: cobra.NoArgs, DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
|
||||
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --event")
|
||||
}
|
||||
if users == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --users")
|
||||
}
|
||||
params := map[string]any{"eventId": eventID, "attendeesToAdd": csvToList(users)}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "optional")); v != "" {
|
||||
params["optional"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
|
||||
params["calendarId"] = v
|
||||
}
|
||||
return runCalendarTool(cmd, runner, "add_calendar_participant", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
|
||||
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
|
||||
cmd.Flags().String("optional", "", i18n.T("是否可选参会人 (可选)"))
|
||||
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarAttendeeDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete", Short: i18n.T("移除日程参会人"),
|
||||
Example: " dws calendar attendee delete --event <eventId> --users userId1", Args: cobra.NoArgs, DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
|
||||
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --event")
|
||||
}
|
||||
if users == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --users")
|
||||
}
|
||||
params := map[string]any{"eventId": eventID, "attendeesToRemove": csvToList(users)}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
|
||||
params["calendarId"] = v
|
||||
}
|
||||
return runCalendarTool(cmd, runner, "remove_calendar_participant", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
|
||||
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
|
||||
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCalendarTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "calendar", tool, params)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, inv)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func csvToList(s string) []any {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newChatBotSearchCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(message, group, bot)
|
||||
root.AddCommand(message, group, bot, newChatFileGroup(runner))
|
||||
return root
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newChatFileGroup builds `dws chat file upload`. wukong implements it as a
|
||||
// multi-step upload to the conversation file space: for --file it does
|
||||
// init_conversation_file_upload (im) -> HTTP PUT -> commit_conversation_file_upload
|
||||
// (im); for --url it calls upload_conversation_file_by_url (chat). The envelope
|
||||
// cannot express the local pipeline, so it lives here. Wired into the chat
|
||||
// handler (see chat.go).
|
||||
func newChatFileGroup(runner executor.Runner) *cobra.Command {
|
||||
file := &cobra.Command{
|
||||
Use: "file",
|
||||
Short: "会话文件上传",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
file.AddCommand(newChatFileUploadCommand(runner))
|
||||
return file
|
||||
}
|
||||
|
||||
func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传本地文件或 URL 文件到会话文件空间",
|
||||
Example: " dws chat file upload --group <openConversationId> --file ./report.pdf\n" +
|
||||
" dws chat file upload --user <userId> --url https://example.com/a.pdf --file-name a.pdf",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := chatConversationTargetArgs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file", "file-path"))
|
||||
fileURL := strings.TrimSpace(firstNonEmptyFlag(cmd, "url"))
|
||||
if filePath == "" && fileURL == "" {
|
||||
return apperrors.NewValidation("--file or --url is required")
|
||||
}
|
||||
if filePath != "" && fileURL != "" {
|
||||
return apperrors.NewValidation("--file and --url are mutually exclusive")
|
||||
}
|
||||
fileName := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-name"))
|
||||
md5v := strings.TrimSpace(firstNonEmptyFlag(cmd, "md5"))
|
||||
uuid := strings.TrimSpace(firstNonEmptyFlag(cmd, "uuid"))
|
||||
|
||||
// URL path: server pulls the file (chat server).
|
||||
if fileURL != "" {
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(fileURL)
|
||||
}
|
||||
params := cloneStringAnyMap(target)
|
||||
params["fileUrl"] = fileURL
|
||||
params["fileName"] = fileName
|
||||
if md5v != "" {
|
||||
params["md5"] = md5v
|
||||
}
|
||||
if uuid != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "chat", "upload_conversation_file_by_url", params)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, inv)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
// Local path: init (im) -> HTTP PUT -> commit (im).
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("cannot read file " + filePath + ": " + err.Error())
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return apperrors.NewValidation(filePath + " is a directory, not a file")
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
if md5v == "" {
|
||||
if md5v, err = fileMD5Hex(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
preview := cloneStringAnyMap(target)
|
||||
preview["fileName"] = fileName
|
||||
preview["fileSize"] = fileSize
|
||||
preview["md5"] = md5v
|
||||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", preview))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
initParams := cloneStringAnyMap(target)
|
||||
initParams["fileName"] = fileName
|
||||
initParams["fileSize"] = fileSize
|
||||
initParams["md5"] = md5v
|
||||
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", initParams))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
|
||||
if resourceURL == "" {
|
||||
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
|
||||
}
|
||||
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
|
||||
if resourceURL == "" || uploadKey == "" {
|
||||
return apperrors.NewAPI("incomplete upload credentials: resourceUrl=" + resourceURL + " uploadKey=" + uploadKey)
|
||||
}
|
||||
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
|
||||
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commitParams := cloneStringAnyMap(target)
|
||||
commitParams["uploadKey"] = uploadKey
|
||||
commitParams["fileName"] = fileName
|
||||
commitParams["fileSize"] = fileSize
|
||||
commitParams["md5"] = md5v
|
||||
if uuid != "" {
|
||||
commitParams["uuid"] = uuid
|
||||
}
|
||||
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "im", "commit_conversation_file_upload", commitParams))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, commitRes)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
|
||||
cmd.Flags().String("conversation-id", "", "--group 的别名")
|
||||
cmd.Flags().String("id", "", "--group 的别名")
|
||||
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
|
||||
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
|
||||
cmd.Flags().String("file-path", "", "--file 的别名")
|
||||
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
|
||||
cmd.Flags().String("file-name", "", "文件名(可选)")
|
||||
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
|
||||
cmd.Flags().String("uuid", "", "幂等 UUID(可选)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
|
||||
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
|
||||
openDingTalkID := strings.TrimSpace(firstNonEmptyFlag(cmd, "open-dingtalk-id"))
|
||||
if group == "" && user == "" && openDingTalkID == "" {
|
||||
return nil, apperrors.NewValidation("需指定会话目标:--group(群聊)或 --user / --open-dingtalk-id(单聊)之一")
|
||||
}
|
||||
m := map[string]any{}
|
||||
if group != "" {
|
||||
m["openConversationId"] = group
|
||||
}
|
||||
if user != "" {
|
||||
m["userId"] = user
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
m["openDingTalkId"] = openDingTalkID
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in)+4)
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
+124
-2
@@ -85,6 +85,7 @@ func (docHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newDocPermissionAddCommand(runner),
|
||||
newDocPermissionUpdateCommand(runner),
|
||||
newDocPermissionListCommand(runner),
|
||||
newDocPermissionRemoveCommand(runner),
|
||||
)
|
||||
|
||||
export := &cobra.Command{
|
||||
@@ -174,6 +175,7 @@ func newDocSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive search or dws wiki node search")
|
||||
params := map[string]any{}
|
||||
if keyword := docFlagOrFallback(cmd, "query", "keyword"); keyword != "" {
|
||||
params["keyword"] = keyword
|
||||
@@ -222,6 +224,7 @@ func newDocListCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive list or dws wiki node list")
|
||||
params := map[string]any{}
|
||||
if folder := docFlagOrFallback(cmd, "folder", "parent-id", "node", "file-id", "nodee"); folder != "" {
|
||||
params["folderId"] = normalizeDocNodeID(folder)
|
||||
@@ -274,6 +277,9 @@ func newDocReadCommand(runner executor.Runner) *cobra.Command {
|
||||
if output := docStringFlag(cmd, "output"); output != "" {
|
||||
params["__output__"] = output
|
||||
}
|
||||
if format == "jsonml" {
|
||||
return runDocReadJSONML(cmd, runner, params)
|
||||
}
|
||||
return runDocTool(cmd, runner, "doc", "get_document_content", params)
|
||||
},
|
||||
}
|
||||
@@ -814,6 +820,7 @@ func newDocFileCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "wiki node create")
|
||||
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -850,6 +857,7 @@ func newDocFolderCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "wiki node create --type folder")
|
||||
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -889,6 +897,7 @@ func newDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Comm
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive "+use)
|
||||
nodeID, err := docRequiredNode(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -919,6 +928,7 @@ func newDocRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive rename")
|
||||
nodeID, err := docRequiredNode(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1467,6 +1477,56 @@ func docInvocationResult(cmd *cobra.Command, runner executor.Runner, product, to
|
||||
return runner.Run(cmd.Context(), invocation)
|
||||
}
|
||||
|
||||
func runDocReadJSONML(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
|
||||
outputPath, _ := params["__output__"].(string)
|
||||
result, err := docInvocationResult(cmd, runner, "doc", "get_document_content", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !result.Invocation.Implemented {
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
payload := normalizeDocReadJSONMLResult(result)
|
||||
if outputPath != "" {
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSONML output: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "[INFO] JSONML 已写入 %s\n", outputPath)
|
||||
return nil
|
||||
}
|
||||
return writeCommandPayload(cmd, payload)
|
||||
}
|
||||
|
||||
func normalizeDocReadJSONMLResult(result executor.Result) map[string]any {
|
||||
content := result.Response
|
||||
if nested, ok := result.Response["content"].(map[string]any); ok {
|
||||
content = nested
|
||||
}
|
||||
out := map[string]any{}
|
||||
for k, v := range content {
|
||||
if k == "content" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if raw, ok := out["jsonml"].(string); ok {
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
|
||||
out["jsonml"] = decoded
|
||||
}
|
||||
}
|
||||
if revision, ok := out["version"]; ok {
|
||||
if _, exists := out["revision"]; !exists {
|
||||
out["revision"] = revision
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addDocNodeFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("node", "", i18n.T("文档 nodeId / URL"))
|
||||
addDocHiddenStringFlag(cmd, "url", "--node alias")
|
||||
@@ -2082,6 +2142,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive permission add")
|
||||
return runDocPermissionMutation(cmd, runner, "add_permission")
|
||||
},
|
||||
}
|
||||
@@ -2091,6 +2152,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
|
||||
addDocHiddenStringFlag(cmd, "users", "--user alias")
|
||||
cmd.Flags().String("role", "", i18n.T("权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)"))
|
||||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填,辅助构造返回的 docUrl)"))
|
||||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -2108,6 +2170,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive permission update")
|
||||
return runDocPermissionMutation(cmd, runner, "update_permission")
|
||||
},
|
||||
}
|
||||
@@ -2118,6 +2181,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
addDocHiddenStringFlag(cmd, "uid", "--user alias")
|
||||
cmd.Flags().String("role", "", i18n.T("新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)"))
|
||||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
|
||||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -2135,6 +2199,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive permission list")
|
||||
nodeID, err := docRequiredNode(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2157,7 +2222,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
params["filterRoleIds"] = roles
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("workspace"); v != "" {
|
||||
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
params["workspaceId"] = v
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
@@ -2183,6 +2248,59 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
|
||||
_ = cmd.Flags().MarkHidden("page-size")
|
||||
cmd.Flags().String("filter-role", "", i18n.T("按角色过滤(逗号分隔): OWNER / MANAGER / EDITOR / DOWNLOADER / READER"))
|
||||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
|
||||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDocPermissionRemoveCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: i18n.T("移除文档协作者权限"),
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
docDeprecatedNotice(cmd, "drive permission remove")
|
||||
nodeID, err := docRequiredNode(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawUsers := docFlagOrFallback(cmd, "users", "user", "uid")
|
||||
if strings.TrimSpace(rawUsers) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
userIDs, err := parseDocPermissionUsers(rawUsers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIDs,
|
||||
}
|
||||
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
params["workspaceId"] = v
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
|
||||
))
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addDocNodeFlags(cmd)
|
||||
cmd.Flags().String("users", "", i18n.T("被移除用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
|
||||
addDocHiddenStringFlag(cmd, "user", "--users alias")
|
||||
addDocHiddenStringFlag(cmd, "uid", "--users alias")
|
||||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
|
||||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -2217,7 +2335,7 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
|
||||
"roleId": role,
|
||||
"userIds": userIDs,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("workspace"); v != "" {
|
||||
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
params["workspaceId"] = v
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
@@ -2234,6 +2352,10 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func docDeprecatedNotice(cmd *cobra.Command, replacement string) {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: deprecated: use dws %s instead.\n", replacement)
|
||||
}
|
||||
|
||||
// TRANSITIONAL: 等 mse 把 delete_document 加入 doc toolOverrides(含
|
||||
// destructive_hint: true)后,本 helper 可删除——CLI discovery 会自动
|
||||
// 生成等价命令。工单:plan/mse-yuyuan-patch.md 改动 2.2。
|
||||
|
||||
@@ -96,6 +96,35 @@ func TestDocPermissionListLimitAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocPermissionRemoveRoutesToRemovePermission(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &docCommandRunner{}
|
||||
cmd := newDocTestRoot(runner)
|
||||
_, errOut, err := executeDocCommand(t, cmd,
|
||||
"permission", "rm",
|
||||
"--node", "NODE_001",
|
||||
"--users", "uid1,uid2",
|
||||
"--workspace", "WS_001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut)
|
||||
}
|
||||
if runner.last.Tool != "remove_permission" {
|
||||
t.Fatalf("tool = %q, want remove_permission", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
|
||||
t.Fatalf("nodeId = %#v, want NODE_001", got)
|
||||
}
|
||||
users, ok := runner.last.Params["userIds"].([]string)
|
||||
if !ok || strings.Join(users, ",") != "uid1,uid2" {
|
||||
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
|
||||
}
|
||||
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
|
||||
t.Fatalf("workspaceId = %#v, want WS_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocPermissionListMaxresultsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ func (driveHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newDriveCommitCommand(runner),
|
||||
newDriveUploadCommand(runner),
|
||||
newDriveDeleteCommand(runner),
|
||||
newDriveSearchCommand(runner),
|
||||
newDriveCopyCommand(runner),
|
||||
newDriveMoveCommand(runner),
|
||||
newDriveRenameCommand(runner),
|
||||
newDrivePermissionCommand(runner),
|
||||
newDriveFolderCommand(runner),
|
||||
)
|
||||
return root
|
||||
}
|
||||
@@ -98,6 +104,17 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
|
||||
if maxResults <= 0 {
|
||||
maxResults = 20
|
||||
}
|
||||
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
|
||||
params := map[string]any{"workspaceId": workspaceID}
|
||||
if folderID := driveFlagOrFallback(cmd, "folder", "parent-id"); folderID != "" {
|
||||
params["folderId"] = normalizeDocNodeID(folderID)
|
||||
}
|
||||
if maxResults > 0 {
|
||||
params["pageSize"] = maxResults
|
||||
}
|
||||
addDriveStringParam(cmd, params, "pageToken", "cursor", "next-token")
|
||||
return runDriveInvocation(cmd, runner, "doc", "list_nodes", params)
|
||||
}
|
||||
params := map[string]any{"maxResults": float64(maxResults)}
|
||||
addDriveStringParam(cmd, params, "spaceId", "space-id")
|
||||
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
|
||||
@@ -124,6 +141,8 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」对应 spaceId (可选)")
|
||||
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则列出空间根目录 (可选)")
|
||||
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
|
||||
cmd.Flags().String("workspace", "", "文档空间/知识库 ID,传入则路由到文档空间")
|
||||
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
cmd.Flags().String("cursor", "", "分页游标,首次不传 (可选)")
|
||||
addDriveHiddenStringFlag(cmd, "next-token", "--cursor 的兼容别名")
|
||||
cmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选)")
|
||||
@@ -147,6 +166,7 @@ spaceType 筛选规则:
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki space list instead.")
|
||||
params := map[string]any{}
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
@@ -380,6 +400,9 @@ func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断 (可选)")
|
||||
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
|
||||
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
|
||||
cmd.Flags().String("workspace", "", "目标知识库 ID,传入时路由到文档空间上传")
|
||||
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
cmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档(文档空间上传时生效)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -388,6 +411,9 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return apperrors.NewValidation("--file is required")
|
||||
}
|
||||
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
|
||||
return runDriveUploadToDoc(cmd, runner, workspaceID)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
@@ -493,6 +519,93 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func runDriveUploadToDoc(cmd *cobra.Command, runner executor.Runner, workspaceID string) error {
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
|
||||
}
|
||||
fi, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return apperrors.NewValidation("--file 不能是目录: " + absPath)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
if fileSize <= 0 {
|
||||
return apperrors.NewValidation("文件为空")
|
||||
}
|
||||
|
||||
fileName := driveFlagOrFallback(cmd, "file-name", "name")
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(absPath)
|
||||
}
|
||||
folderID := driveFlagOrFallback(cmd, "folder", "parent-id")
|
||||
if folderID != "" {
|
||||
folderID = normalizeDocNodeID(folderID)
|
||||
}
|
||||
|
||||
step1Params := map[string]any{"workspaceId": workspaceID}
|
||||
if folderID != "" {
|
||||
step1Params["folderId"] = folderID
|
||||
}
|
||||
commitParams := map[string]any{
|
||||
"name": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
"workspaceId": workspaceID,
|
||||
}
|
||||
if folderID != "" {
|
||||
commitParams["folderId"] = folderID
|
||||
}
|
||||
if convert, _ := cmd.Flags().GetBool("convert"); convert {
|
||||
commitParams["convertToOnlineDoc"] = true
|
||||
}
|
||||
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, map[string]any{
|
||||
"dry_run": true,
|
||||
"step_1_get_file_upload_info": executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
|
||||
),
|
||||
"step_2_http_put_oss": "PUT file bytes to resourceUrl with returned headers",
|
||||
"step_3_commit_uploaded_file": executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
|
||||
),
|
||||
"file": absPath,
|
||||
"name": fileName,
|
||||
"size": fileSize,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[1/3] 获取文档空间上传凭证 %s (%d 字节)...\n", fileName, fileSize)
|
||||
step1Result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取上传凭证失败: %w", err)
|
||||
}
|
||||
resourceURL, uploadKey, headers, err := extractDocFileUploadInfo(step1Result.Response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "[2/3] 上传文件到 OSS...")
|
||||
if err := httpPutDriveFile(cmd.Context(), resourceURL, headers, absPath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "[3/3] 提交文件入库...")
|
||||
commitParams["uploadKey"] = uploadKey
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交文件入库失败: %w", err)
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
// ── delete (drive surface routed to doc MCP server) ────────
|
||||
|
||||
func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
@@ -528,6 +641,303 @@ func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "搜索文件(聚合钉盘和文档空间)",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyword := driveFlagOrFallback(cmd, "query", "keyword")
|
||||
if keyword == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
target := driveStringFlag(cmd, "target")
|
||||
driveParams := map[string]any{"keyword": keyword}
|
||||
if target != "" && target != "all" {
|
||||
driveParams["searchTarget"] = target
|
||||
}
|
||||
addDriveStringSliceParam(cmd, driveParams, "fileTypes", "file-types")
|
||||
addDriveStringSliceParam(cmd, driveParams, "extensions", "extensions")
|
||||
addDriveStringSliceParam(cmd, driveParams, "creatorUserIds", "creator-uids")
|
||||
addDriveInt64Param(cmd, driveParams, "createdTimeFrom", "created-from")
|
||||
addDriveInt64Param(cmd, driveParams, "createdTimeTo", "created-to")
|
||||
addDriveInt64Param(cmd, driveParams, "modifiedTimeFrom", "modified-from")
|
||||
addDriveInt64Param(cmd, driveParams, "modifiedTimeTo", "modified-to")
|
||||
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
|
||||
driveParams["pageSize"] = float64(pageSize)
|
||||
}
|
||||
addDriveStringParam(cmd, driveParams, "pageToken", "cursor", "page-token")
|
||||
|
||||
if target == "file" || target == "space" {
|
||||
return runDriveInvocation(cmd, runner, "drive", "search_files", driveParams)
|
||||
}
|
||||
|
||||
driveResult, driveErr := driveInvocationResult(cmd, runner, "drive", "search_files", driveParams)
|
||||
docParams := map[string]any{"keyword": keyword}
|
||||
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
|
||||
docParams["pageSize"] = pageSize
|
||||
}
|
||||
if extensions, ok := driveParams["extensions"]; ok {
|
||||
docParams["extensions"] = extensions
|
||||
}
|
||||
docResult, docErr := driveInvocationResult(cmd, runner, "doc", "search_documents", docParams)
|
||||
if driveErr != nil && docErr != nil {
|
||||
return fmt.Errorf("aggregated search failed: drive: %v; doc: %v", driveErr, docErr)
|
||||
}
|
||||
result := map[string]any{}
|
||||
if driveErr == nil {
|
||||
result["drive_results"] = driveResult.Response
|
||||
}
|
||||
if docErr == nil {
|
||||
result["doc_results"] = docResult.Response
|
||||
}
|
||||
return writeCommandPayload(cmd, map[string]any{"success": true, "result": result})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
addDriveHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
|
||||
cmd.Flags().String("target", "", "搜索范围: all(默认) / file / space")
|
||||
cmd.Flags().StringSlice("file-types", nil, "按文件内容类型过滤")
|
||||
cmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤")
|
||||
cmd.Flags().StringSlice("creator-uids", nil, "按创建者 userId 过滤")
|
||||
cmd.Flags().Int64("created-from", 0, "创建时间起始毫秒时间戳")
|
||||
cmd.Flags().Int64("created-to", 0, "创建时间截止毫秒时间戳")
|
||||
cmd.Flags().Int64("modified-from", 0, "修改时间起始毫秒时间戳")
|
||||
cmd.Flags().Int64("modified-to", 0, "修改时间截止毫秒时间戳")
|
||||
cmd.Flags().Int("limit", 0, "每页返回数量")
|
||||
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("page-size")
|
||||
cmd.Flags().String("cursor", "", "分页游标")
|
||||
addDriveHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveCopyCommand(runner executor.Runner) *cobra.Command {
|
||||
return newDriveDocTransferCommand(runner, "copy", "copy_document")
|
||||
}
|
||||
|
||||
func newDriveMoveCommand(runner executor.Runner) *cobra.Command {
|
||||
return newDriveDocTransferCommand(runner, "move", "move_document")
|
||||
}
|
||||
|
||||
func newDriveDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: use + " 文档空间节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
|
||||
if folder := driveFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
|
||||
params["targetFolderId"] = normalizeDocNodeID(folder)
|
||||
}
|
||||
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
|
||||
return runDriveInvocation(cmd, runner, "doc", tool, params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addDriveDocNodeFlags(cmd)
|
||||
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
|
||||
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
|
||||
cmd.Flags().String("workspace", "", "目标知识库 ID")
|
||||
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "重命名文档空间节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runDriveInvocation(cmd, runner, "doc", "rename_document", map[string]any{
|
||||
"nodeId": normalizeDocNodeID(nodeID),
|
||||
"newName": name,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addDriveDocNodeFlags(cmd)
|
||||
cmd.Flags().String("name", "", "新名称 (必填)")
|
||||
addDriveHiddenStringFlag(cmd, "title", "--name 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDrivePermissionCommand(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "permission",
|
||||
Aliases: []string{"perm"},
|
||||
Short: "文档空间节点权限管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
root.AddCommand(
|
||||
newDrivePermissionMutationCommand(runner, "add", "add_permission", true),
|
||||
newDrivePermissionMutationCommand(runner, "update", "update_permission", true),
|
||||
newDrivePermissionListCommand(runner),
|
||||
newDrivePermissionRemoveCommand(runner),
|
||||
)
|
||||
preferLegacyLeaf(root)
|
||||
return root
|
||||
}
|
||||
|
||||
func newDrivePermissionMutationCommand(runner executor.Runner, use, tool string, requireRole bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: use + " 文档空间节点权限",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawUsers := driveFlagOrFallback(cmd, "users", "user", "uid")
|
||||
if rawUsers == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
userIDs, err := parseDocPermissionUsers(rawUsers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"nodeId": normalizeDocNodeID(nodeID),
|
||||
"userIds": userIDs,
|
||||
}
|
||||
if requireRole {
|
||||
rawRole, err := driveRequiredFlag(cmd, "role")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, ok := normalizeDocPermissionRole(rawRole)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid --role: %s", rawRole))
|
||||
}
|
||||
params["roleId"] = role
|
||||
}
|
||||
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
|
||||
return runDriveInvocation(cmd, runner, "doc", tool, params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addDriveDocNodeFlags(cmd)
|
||||
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
addDriveHiddenStringFlag(cmd, "user", "--users 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
|
||||
if requireRole {
|
||||
cmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
}
|
||||
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDrivePermissionListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "查询文档空间节点协作者",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
|
||||
if limit := driveIntFlagOrFallback(cmd, "limit", "max-results", "page-size"); limit > 0 {
|
||||
params["maxResults"] = limit
|
||||
}
|
||||
if filterRole := driveStringFlag(cmd, "filter-role"); filterRole != "" {
|
||||
params["filterRoleIds"] = parseDriveRoleList(filterRole)
|
||||
}
|
||||
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
|
||||
return runDriveInvocation(cmd, runner, "doc", "list_permission", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addDriveDocNodeFlags(cmd)
|
||||
cmd.Flags().Int("limit", 30, "返回成员数上限")
|
||||
cmd.Flags().Int("max-results", 0, "--limit 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("max-results")
|
||||
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("page-size")
|
||||
cmd.Flags().String("filter-role", "", "按角色过滤,逗号分隔")
|
||||
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDrivePermissionRemoveCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := newDrivePermissionMutationCommand(runner, "remove", "remove_permission", false)
|
||||
cmd.Aliases = []string{"rm"}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveFolderCommand(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "folder",
|
||||
Short: "文档空间文件夹兼容入口(deprecated)",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
create := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建文档空间文件夹(deprecated)",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki node create --type folder instead.")
|
||||
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"name": name}
|
||||
if folder := driveFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
|
||||
params["folderId"] = normalizeDocNodeID(folder)
|
||||
}
|
||||
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
|
||||
return runDriveInvocation(cmd, runner, "doc", "create_folder", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(root)
|
||||
preferLegacyLeaf(create)
|
||||
create.Flags().String("name", "", "文件夹名称 (必填)")
|
||||
addDriveHiddenStringFlag(create, "title", "--name 的兼容别名")
|
||||
create.Flags().String("folder", "", "父文件夹 nodeId 或 URL")
|
||||
addDriveHiddenStringFlag(create, "parent-id", "--folder 的兼容别名")
|
||||
create.Flags().String("workspace", "", "目标知识库 ID")
|
||||
addDriveHiddenStringFlag(create, "workspace-id", "--workspace 的兼容别名")
|
||||
root.AddCommand(create)
|
||||
root.Hidden = true
|
||||
return root
|
||||
}
|
||||
|
||||
func runDriveInfo(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
|
||||
result, err := driveInvocationResult(cmd, runner, "drive", "get_file_info", params)
|
||||
if err != nil {
|
||||
@@ -814,6 +1224,64 @@ func addDriveStringParam(cmd *cobra.Command, params map[string]any, paramName st
|
||||
}
|
||||
}
|
||||
|
||||
func addDriveStringSliceParam(cmd *cobra.Command, params map[string]any, paramName, flag string) {
|
||||
if !cmd.Flags().Changed(flag) {
|
||||
return
|
||||
}
|
||||
values, err := cmd.Flags().GetStringSlice(flag)
|
||||
if err != nil || len(values) == 0 {
|
||||
return
|
||||
}
|
||||
cleaned := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if item := strings.TrimSpace(part); item != "" {
|
||||
cleaned = append(cleaned, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
params[paramName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
func addDriveInt64Param(cmd *cobra.Command, params map[string]any, paramName, flag string) {
|
||||
if !cmd.Flags().Changed(flag) {
|
||||
return
|
||||
}
|
||||
value, err := cmd.Flags().GetInt64(flag)
|
||||
if err == nil && value > 0 {
|
||||
params[paramName] = value
|
||||
}
|
||||
}
|
||||
|
||||
func addDriveDocNodeFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
|
||||
addDriveHiddenStringFlag(cmd, "url", "--node 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "id", "--node 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
|
||||
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
|
||||
}
|
||||
|
||||
func parseDriveRoleList(raw string) []string {
|
||||
roles := make([]string, 0)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
role := strings.ToUpper(strings.TrimSpace(part))
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
if normalized, ok := normalizeDocPermissionRole(role); ok {
|
||||
roles = append(roles, normalized)
|
||||
continue
|
||||
}
|
||||
if role == "OWNER" {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
func addDriveHiddenStringFlag(cmd *cobra.Command, name, usage string) {
|
||||
cmd.Flags().String(name, "", usage)
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
|
||||
@@ -21,19 +21,24 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
type driveCommandRunner struct {
|
||||
calls int
|
||||
all []executor.Invocation
|
||||
last executor.Invocation
|
||||
result executor.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *driveCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls++
|
||||
r.last = invocation
|
||||
r.all = append(r.all, invocation)
|
||||
if r.err != nil {
|
||||
return executor.Result{}, r.err
|
||||
}
|
||||
@@ -65,6 +70,109 @@ func TestDriveListPageSizeAliasMapsMaxResults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListWorkspaceRoutesToDocListNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &driveCommandRunner{}
|
||||
cmd := newDriveListCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--workspace-id", "WS_001", "--folder", "FOLDER_001", "--limit", "10"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.CanonicalProduct != "doc" {
|
||||
t.Fatalf("product = %q, want doc", runner.last.CanonicalProduct)
|
||||
}
|
||||
if runner.last.Tool != "list_nodes" {
|
||||
t.Fatalf("tool = %q, want list_nodes", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
|
||||
t.Fatalf("workspaceId = %#v, want WS_001", got)
|
||||
}
|
||||
if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
|
||||
t.Fatalf("folderId = %#v, want FOLDER_001", got)
|
||||
}
|
||||
if got := runner.last.Params["pageSize"]; got != 10 {
|
||||
t.Fatalf("pageSize = %#v, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyAliasesRouteToDoc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &driveCommandRunner{}
|
||||
cmd := newDriveCopyCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--file-id", "NODE_001", "--parent-id", "FOLDER_001", "--workspace-id", "WS_001"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "copy_document" {
|
||||
t.Fatalf("invocation = %#v, want doc copy_document", runner.last)
|
||||
}
|
||||
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
|
||||
t.Fatalf("nodeId = %#v, want NODE_001", got)
|
||||
}
|
||||
if got := runner.last.Params["targetFolderId"]; got != "FOLDER_001" {
|
||||
t.Fatalf("targetFolderId = %#v, want FOLDER_001", got)
|
||||
}
|
||||
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
|
||||
t.Fatalf("workspaceId = %#v, want WS_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionRemoveRoutesToDoc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &driveCommandRunner{}
|
||||
cmd := newDrivePermissionCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"remove", "--node", "NODE_001", "--users", "uid1,uid2"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "remove_permission" {
|
||||
t.Fatalf("invocation = %#v, want doc remove_permission", runner.last)
|
||||
}
|
||||
users, ok := runner.last.Params["userIds"].([]string)
|
||||
if !ok || strings.Join(users, ",") != "uid1,uid2" {
|
||||
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSearchAggregatesDriveAndDoc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &driveCommandRunner{}
|
||||
cmd := newDriveSearchCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--query", "报告", "--extensions", "pdf,docx", "--limit", "5"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.all) != 2 {
|
||||
t.Fatalf("calls = %d, want 2", len(runner.all))
|
||||
}
|
||||
if runner.all[0].CanonicalProduct != "drive" || runner.all[0].Tool != "search_files" {
|
||||
t.Fatalf("first invocation = %#v, want drive search_files", runner.all[0])
|
||||
}
|
||||
if runner.all[1].CanonicalProduct != "doc" || runner.all[1].Tool != "search_documents" {
|
||||
t.Fatalf("second invocation = %#v, want doc search_documents", runner.all[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadOutputDirectoryUsesServerFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler { return mailHandler{} })
|
||||
}
|
||||
|
||||
// mailHandler contributes the hardcoded `mail message list` leaf. The MCP
|
||||
// backend has no dedicated "list by folder" tool — wukong implements it by
|
||||
// calling search_emails with a synthesised KQL query (folderId:<id>). The
|
||||
// envelope/discovery layer cannot express that query construction (pipeline
|
||||
// $flag templates resolve raw flag values and do not support string
|
||||
// interpolation), so it lives here as a helper leaf. MergeCommandTree folds
|
||||
// this single leaf into the envelope-driven mail tree without disturbing the
|
||||
// other mail commands.
|
||||
type mailHandler struct{}
|
||||
|
||||
func (mailHandler) Name() string { return "mail" }
|
||||
|
||||
func (mailHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "mail",
|
||||
Short: i18n.T("邮箱"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
message := &cobra.Command{
|
||||
Use: "message",
|
||||
Short: i18n.T("邮件管理"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
message.AddCommand(newMailMessageListCommand(runner))
|
||||
root.AddCommand(message)
|
||||
return root
|
||||
}
|
||||
|
||||
// newMailMessageListCommand builds `mail message list`, listing emails in a
|
||||
// folder via search_emails with query=folderId:<id> (default inbox=2).
|
||||
func newMailMessageListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("列出文件夹中的邮件"),
|
||||
Example: " dws mail message list --email user@company.com # 默认列出收件箱\n" +
|
||||
" dws mail message list --email user@company.com --folder-id 1 --limit 50",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
email := strings.TrimSpace(firstNonEmptyFlag(cmd, "email"))
|
||||
if email == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --email")
|
||||
}
|
||||
folderID := strings.TrimSpace(firstNonEmptyFlag(cmd, "folder-id", "folder"))
|
||||
if folderID == "" {
|
||||
folderID = "2" // inbox
|
||||
}
|
||||
params := map[string]any{
|
||||
"email": email,
|
||||
"query": "folderId:" + folderID,
|
||||
}
|
||||
if size := strings.TrimSpace(firstNonEmptyFlag(cmd, "limit", "size", "page-size")); size != "" {
|
||||
params["size"] = size
|
||||
}
|
||||
if cursor := strings.TrimSpace(firstNonEmptyFlag(cmd, "cursor")); cursor != "" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "mail", "search_emails", params,
|
||||
))
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "mail", "search_emails", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("email", "", i18n.T("邮件所属邮箱地址 (必填)"))
|
||||
cmd.Flags().String("folder-id", "", i18n.T("文件夹 ID (可选, 默认收件箱 2)"))
|
||||
cmd.Flags().String("folder", "", i18n.T("--folder-id 的别名"))
|
||||
cmd.Flags().String("limit", "", i18n.T("每页返回数量 (可选)"))
|
||||
cmd.Flags().String("size", "", i18n.T("--limit 的别名"))
|
||||
cmd.Flags().String("page-size", "", i18n.T("--limit 的别名"))
|
||||
cmd.Flags().String("cursor", "", i18n.T("分页游标 (可选)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// firstNonEmptyFlag returns the first non-empty string flag value among names.
|
||||
func firstNonEmptyFlag(cmd *cobra.Command, names ...string) string {
|
||||
for _, n := range names {
|
||||
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler { return minutesHandler{} })
|
||||
}
|
||||
|
||||
// minutesHandler contributes the hardcoded `minutes list mine|shared|all`
|
||||
// leaves. wukong lists minutes through a single tool
|
||||
// (list_by_keyword_and_time_range) distinguished by belongingConditionId, and
|
||||
// renames the raw MCP response fields (minutesDetails -> itemList,
|
||||
// hasNext -> hasMore) for a stable CLI contract. The envelope/discovery layer
|
||||
// maps these to older split tools and its outputFormat.rename cannot reach the
|
||||
// nested result, so the canonical behaviour lives here. MergeCommandTree folds
|
||||
// these leaves into the envelope-driven minutes tree.
|
||||
type minutesHandler struct{}
|
||||
|
||||
func (minutesHandler) Name() string { return "minutes" }
|
||||
|
||||
func (minutesHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: i18n.T("听记"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("听记列表"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
list.AddCommand(
|
||||
newMinutesListCommand(runner, "mine", "created", i18n.T("查询我创建的听记列表")),
|
||||
newMinutesListCommand(runner, "shared", "shared", i18n.T("查询他人共享给我的听记列表")),
|
||||
newMinutesListCommand(runner, "all", "noLimit", i18n.T("查询我有权限访问的所有听记列表")),
|
||||
)
|
||||
root.AddCommand(list)
|
||||
return root
|
||||
}
|
||||
|
||||
func newMinutesListCommand(runner executor.Runner, use, belonging, short string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
params := map[string]any{"belongingConditionId": belonging}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "limit", "max")); v != "" {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
params["maxResults"] = n
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "query", "keyword")); v != "" {
|
||||
params["keyword"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "cursor", "next-token", "offset")); v != "" {
|
||||
params["nextToken"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "start")); v != "" {
|
||||
ms, err := parseISOToMillis("start", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["createTimeStart"] = float64(ms)
|
||||
}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "end")); v != "" {
|
||||
ms, err := parseISOToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["createTimeEnd"] = float64(ms)
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "minutes", "list_by_keyword_and_time_range", params,
|
||||
)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, inv)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
renameMinutesListFields(result.Response)
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("limit", "", i18n.T("每页返回数量 (可选)"))
|
||||
cmd.Flags().String("max", "", i18n.T("--limit 的别名"))
|
||||
cmd.Flags().String("cursor", "", i18n.T("分页游标 (可选)"))
|
||||
cmd.Flags().String("query", "", i18n.T("关键字筛选 (可选)"))
|
||||
cmd.Flags().String("start", "", i18n.T("起始时间 ISO-8601 (可选)"))
|
||||
cmd.Flags().String("end", "", i18n.T("结束时间 ISO-8601 (可选)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// renameMinutesListFields renames the raw list fields inside the MCP response
|
||||
// content (content.result.{minutesDetails->itemList, hasNext->hasMore}) so the
|
||||
// CLI contract matches wukong. Safe no-op when the shape differs.
|
||||
func renameMinutesListFields(resp map[string]any) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
content, ok := resp["content"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target := content
|
||||
if inner, ok := content["result"].(map[string]any); ok {
|
||||
target = inner
|
||||
}
|
||||
if v, ok := target["minutesDetails"]; ok {
|
||||
target["itemList"] = v
|
||||
delete(target, "minutesDetails")
|
||||
}
|
||||
if v, ok := target["hasNext"]; ok {
|
||||
target["hasMore"] = v
|
||||
delete(target, "hasNext")
|
||||
}
|
||||
}
|
||||
|
||||
func parseISOToMillis(label, s string) (int64, error) {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, apperrors.NewValidation("--" + label + " 时间格式无效,请用 ISO-8601 (如 2026-03-10T14:00:00+08:00)")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// sheet range update 单元格结构的客户端校验,与 wukong 产品层 validateComplexValueCell
|
||||
// 系列保持一致:拦住缺 type / richText 与整格 hyperlink 共存 / hyperlink.text 与
|
||||
// cell.text 不一致 / 非法 dataValidation 等非法输入,避免把脏 payload 发到服务端。
|
||||
|
||||
var sheetComplexValueStyleFields = map[string]string{
|
||||
"bold": "bool",
|
||||
"italic": "bool",
|
||||
"underline": "bool",
|
||||
"strike": "bool",
|
||||
"color": "string",
|
||||
"size": "number",
|
||||
}
|
||||
|
||||
func sheetValidateComplexValueStyle(style map[string]any, path string) error {
|
||||
for k, v := range style {
|
||||
kind, ok := sheetComplexValueStyleFields[k]
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s 含未知字段 %q(合法字段: bold/italic/underline/strike/color/size)", path, k))
|
||||
}
|
||||
switch kind {
|
||||
case "bool":
|
||||
if _, ok := v.(bool); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为 boolean", path, k))
|
||||
}
|
||||
case "string":
|
||||
if _, ok := v.(string); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为字符串(如 \"#FF0000\")", path, k))
|
||||
}
|
||||
case "number":
|
||||
if _, ok := v.(float64); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为数字", path, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sheetValidateRichTextItem(item map[string]any, path string) error {
|
||||
typeRaw, ok := item["type"]
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/link/attachment/image)", path))
|
||||
}
|
||||
typeVal, ok := typeRaw.(string)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
|
||||
}
|
||||
switch typeVal {
|
||||
case "text":
|
||||
if _, ok := item["text"].(string); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=text 必须包含 text 字符串字段", path))
|
||||
}
|
||||
case "link":
|
||||
if _, ok := item["text"].(string); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含 text 字符串字段(显示文字)", path))
|
||||
}
|
||||
if s, ok := item["link"].(string); !ok || s == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含非空 link 字符串字段(超链接 URL)", path))
|
||||
}
|
||||
case "attachment":
|
||||
if _, ok := item["text"].(string); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含 text 字符串字段(显示文件名)", path))
|
||||
}
|
||||
if s, ok := item["resourceId"].(string); !ok || s == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
|
||||
}
|
||||
if s, ok := item["mimeType"].(string); !ok || s == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 mimeType 字符串字段", path))
|
||||
}
|
||||
case "image":
|
||||
if s, ok := item["resourceId"].(string); !ok || s == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
|
||||
}
|
||||
if s, ok := item["resourceUrl"].(string); !ok || s == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceUrl 字符串字段", path))
|
||||
}
|
||||
default:
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text/link/attachment/image)", path, typeVal))
|
||||
}
|
||||
if styleRaw, exists := item["style"]; exists {
|
||||
if typeVal != "text" && typeVal != "link" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: style 字段仅 type=text / link 子项支持", path))
|
||||
}
|
||||
style, ok := styleRaw.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
|
||||
}
|
||||
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sheetValidateCellHyperlink(raw any, path string) error {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
hyperlink, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object 或 null", path))
|
||||
}
|
||||
typeRaw, ok := hyperlink["type"]
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: path / sheet / range / none)", path))
|
||||
}
|
||||
typeVal, ok := typeRaw.(string)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
|
||||
}
|
||||
switch typeVal {
|
||||
case "path", "sheet", "range":
|
||||
linkRaw, ok := hyperlink["link"]
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 link 字段", path))
|
||||
}
|
||||
link, ok := linkRaw.(string)
|
||||
if !ok || strings.TrimSpace(link) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.link 必须为非空字符串", path))
|
||||
}
|
||||
if textRaw, exists := hyperlink["text"]; exists {
|
||||
if _, ok := textRaw.(string); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串", path))
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
// type=none 表示显式清除单元格级超链接,不需其他字段
|
||||
default:
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: path / sheet / range / none)", path, typeVal))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sheetValidateDataValidation(dvRaw any, path string) error {
|
||||
dv, ok := dvRaw.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object", path))
|
||||
}
|
||||
dvType, ok := dv["type"].(string)
|
||||
if !ok || dvType == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为非空字符串(合法值: dropdown / checkbox / none)", path))
|
||||
}
|
||||
switch dvType {
|
||||
case "dropdown":
|
||||
optionsRaw, ok := dv["options"]
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=dropdown 必须包含 options 数组", path))
|
||||
}
|
||||
options, ok := optionsRaw.([]any)
|
||||
if !ok || len(options) == 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.options 必须为非空数组", path))
|
||||
}
|
||||
for i, opt := range options {
|
||||
optMap, ok := opt.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i))
|
||||
}
|
||||
val, ok := optMap["value"].(string)
|
||||
if !ok || val == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d].value 必须为非空字符串", path, i))
|
||||
}
|
||||
}
|
||||
case "checkbox":
|
||||
if c, exists := dv["checked"]; exists {
|
||||
if _, ok := c.(bool); !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.checked 必须为 boolean", path))
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
// type=none 表示显式清除单元格 DV,不需其他字段
|
||||
default:
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: dropdown / checkbox / none)", path, dvType))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sheet range read 回读投影:MCP 框架会按 schema 把 dataValidation / hyperlink
|
||||
// 填成"全字段 null"的空壳,导致清除后回读仍带着这些 key。与 wukong
|
||||
// callMCPToolCellInfos 一致:递归找到 cells,把全 null 的空壳 delete 掉,
|
||||
// 让"清除后回读不应再有该 key"的语义成立。
|
||||
func sheetCleanCellInfos(v any) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
if cellsRaw, ok := t["cells"]; ok {
|
||||
sheetStripCellsMeta(cellsRaw)
|
||||
}
|
||||
for _, child := range t {
|
||||
sheetCleanCellInfos(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
sheetCleanCellInfos(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sheetStripCellsMeta(cellsRaw any) {
|
||||
rows, ok := cellsRaw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
rowSlice, ok := row.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, cell := range rowSlice {
|
||||
cellMap, ok := cell.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if dv, ok := cellMap["dataValidation"]; ok && sheetIsEmptyMetaShell(dv) {
|
||||
delete(cellMap, "dataValidation")
|
||||
}
|
||||
if hl, ok := cellMap["hyperlink"]; ok && sheetIsEmptyMetaShell(hl) {
|
||||
delete(cellMap, "hyperlink")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sheet info 坐标投影:只向 agent 暴露 A1/UI 语义的 nonEmptyRange
|
||||
// (range / lastCell / lastRow / lastColumn),并清掉服务端的 legacy 0-based
|
||||
// 字段。与 wukong normalizeSheetInfoCoordinatesForAgent 一致。
|
||||
func sheetNormalizeInfoCoordinates(v any) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
_, hasNonEmpty := t["nonEmptyRange"]
|
||||
_, hasLegacyRow := t["lastNonEmptyRow"]
|
||||
if hasNonEmpty || hasLegacyRow {
|
||||
sheetApplyNonEmptyRange(t)
|
||||
}
|
||||
for _, child := range t {
|
||||
sheetNormalizeInfoCoordinates(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
sheetNormalizeInfoCoordinates(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sheetApplyNonEmptyRange(sheet map[string]any) {
|
||||
if ner := sheetNormalizeNonEmptyRangeObject(sheet["nonEmptyRange"]); ner != nil {
|
||||
sheet["nonEmptyRange"] = ner
|
||||
} else if ner := sheetBuildNonEmptyRangeFromLegacy(sheet); ner != nil {
|
||||
sheet["nonEmptyRange"] = ner
|
||||
} else {
|
||||
sheet["nonEmptyRange"] = nil
|
||||
}
|
||||
delete(sheet, "lastNonEmptyRow")
|
||||
delete(sheet, "lastNonEmptyColumn")
|
||||
delete(sheet, "lastNonEmptyIndexBase")
|
||||
delete(sheet, "lastNonEmptyRowNumber")
|
||||
delete(sheet, "lastNonEmptyColumnLetter")
|
||||
delete(sheet, "nonEmptyRangeA1")
|
||||
}
|
||||
|
||||
func sheetNormalizeNonEmptyRangeObject(v any) map[string]any {
|
||||
ner, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rangeValue, hasRange := ner["range"].(string)
|
||||
lastCell, hasLastCell := ner["lastCell"].(string)
|
||||
lastRow, hasLastRow := sheetNonNegativeJSONInt(ner["lastRow"])
|
||||
lastColumn, hasLastColumn := ner["lastColumn"].(string)
|
||||
if hasRange && hasLastCell && hasLastRow && hasLastColumn {
|
||||
return map[string]any{
|
||||
"range": rangeValue,
|
||||
"lastCell": lastCell,
|
||||
"lastRow": lastRow,
|
||||
"lastColumn": lastColumn,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sheetBuildNonEmptyRangeFromLegacy(sheet map[string]any) map[string]any {
|
||||
row, hasRow := sheetNonNegativeJSONInt(sheet["lastNonEmptyRow"])
|
||||
col, hasCol := sheetNonNegativeJSONInt(sheet["lastNonEmptyColumn"])
|
||||
if !hasRow || !hasCol {
|
||||
return nil
|
||||
}
|
||||
lastColumnLetter := sheetColumnLetterFromZeroBased(col)
|
||||
lastRowNumber := row + 1
|
||||
lastCellA1 := fmt.Sprintf("%s%d", lastColumnLetter, lastRowNumber)
|
||||
return map[string]any{
|
||||
"range": "A1:" + lastCellA1,
|
||||
"lastCell": lastCellA1,
|
||||
"lastRow": lastRowNumber,
|
||||
"lastColumn": lastColumnLetter,
|
||||
}
|
||||
}
|
||||
|
||||
func sheetNonNegativeJSONInt(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, n >= 0
|
||||
case int64:
|
||||
if n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case float64:
|
||||
if n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
i := int(n)
|
||||
if float64(i) != n {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil || i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int(i), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func sheetColumnLetterFromZeroBased(index int) string {
|
||||
if index < 0 {
|
||||
return ""
|
||||
}
|
||||
index++
|
||||
var b strings.Builder
|
||||
for index > 0 {
|
||||
index--
|
||||
b.WriteByte(byte('A' + index%26))
|
||||
index /= 26
|
||||
}
|
||||
letters := []byte(b.String())
|
||||
for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
|
||||
letters[i], letters[j] = letters[j], letters[i]
|
||||
}
|
||||
return string(letters)
|
||||
}
|
||||
|
||||
// sheetIsEmptyMetaShell 判断 metadata 块是否为全 null 空壳(MCP 框架按 schema 填充的)。
|
||||
func sheetIsEmptyMetaShell(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, vv := range m {
|
||||
if vv != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sheetValidateComplexValueCell(cell map[string]any, path string) error {
|
||||
if dvRaw, exists := cell["dataValidation"]; exists {
|
||||
if err := sheetValidateDataValidation(dvRaw, path+".dataValidation"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if hyperlinkRaw, exists := cell["hyperlink"]; exists {
|
||||
if err := sheetValidateCellHyperlink(hyperlinkRaw, path+".hyperlink"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
typeRaw, hasType := cell["type"]
|
||||
// 没有 type 时,必须有 metadata 字段(不写值,只更新元数据)
|
||||
if !hasType {
|
||||
_, hasDV := cell["dataValidation"]
|
||||
_, hasCS := cell["cellStyles"]
|
||||
_, hasHyperlink := cell["hyperlink"]
|
||||
if hasDV || hasCS || hasHyperlink {
|
||||
return nil
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/richText)", path))
|
||||
}
|
||||
typeVal, ok := typeRaw.(string)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
|
||||
}
|
||||
switch typeVal {
|
||||
case "text":
|
||||
var cellText string
|
||||
hasCellText := false
|
||||
if t, exists := cell["text"]; exists {
|
||||
textValue, ok := t.(string)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串(text=\"\" 表示清空 cell)", path))
|
||||
}
|
||||
cellText = textValue
|
||||
hasCellText = true
|
||||
}
|
||||
if styleRaw, exists := cell["style"]; exists {
|
||||
style, ok := styleRaw.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
|
||||
}
|
||||
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hyperlinkRaw, exists := cell["hyperlink"]; exists && hyperlinkRaw != nil {
|
||||
hyperlink, _ := hyperlinkRaw.(map[string]any)
|
||||
if hyperlinkText, ok := hyperlink["text"].(string); ok && hasCellText && hyperlinkText != cellText {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.hyperlink.text 与 %s.text 不一致,请只传 cell.text 或保持两者相同", path, path))
|
||||
}
|
||||
}
|
||||
case "richText":
|
||||
if _, hasHyperlink := cell["hyperlink"]; hasHyperlink {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: cell-level hyperlink 不能与 type=richText 同时使用;整格链接用 hyperlink,片段链接用 richText.texts[].type=link", path))
|
||||
}
|
||||
textsRaw, ok := cell["texts"]
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s: type=richText 必须包含 texts 数组", path))
|
||||
}
|
||||
texts, ok := textsRaw.([]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.texts 必须为数组", path))
|
||||
}
|
||||
if len(texts) == 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.texts 不能为空数组", path))
|
||||
}
|
||||
for i, item := range texts {
|
||||
itemMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.texts[%d] 必须为 object", path, i))
|
||||
}
|
||||
if err := sheetValidateRichTextItem(itemMap, fmt.Sprintf("%s.texts[%d]", path, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text / richText;不再支持 number/boolean/null,数字布尔请用 {type:text,text:\"...\"} 字符串形式)", path, typeVal))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
type sheetCommandRunner struct {
|
||||
last executor.Invocation
|
||||
calls []executor.Invocation
|
||||
}
|
||||
|
||||
func (r *sheetCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.last = invocation
|
||||
r.calls = append(r.calls, invocation)
|
||||
return executor.Result{Invocation: invocation}, nil
|
||||
}
|
||||
|
||||
func executeSheetCommand(t *testing.T, runner *sheetCommandRunner, args ...string) {
|
||||
t.Helper()
|
||||
cmd := sheetHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateCallsCreateWorkspaceSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner, "create", "--name", "销售数据", "--folder", "FOLDER_001", "--workspace", "WS_001")
|
||||
|
||||
if runner.last.Tool != "create_workspace_sheet" {
|
||||
t.Fatalf("tool = %q, want create_workspace_sheet", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["name"]; got != "销售数据" {
|
||||
t.Fatalf("name = %#v", got)
|
||||
}
|
||||
if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
|
||||
t.Fatalf("folderId = %#v", got)
|
||||
}
|
||||
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
|
||||
t.Fatalf("workspaceId = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetRangeUpdateCallsSetCellRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner,
|
||||
"range", "update",
|
||||
"--node", "NODE_001",
|
||||
"--sheet-id", "SHEET_001",
|
||||
"--range", "A1:B1",
|
||||
"--values", `[[{"type":"text","text":"姓名"},{"type":"text","text":"分数"}]]`,
|
||||
)
|
||||
|
||||
if runner.last.Tool != "set_cell_range" {
|
||||
t.Fatalf("tool = %q, want set_cell_range", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["rangeAddress"]; got != "A1:B1" {
|
||||
t.Fatalf("rangeAddress = %#v", got)
|
||||
}
|
||||
cells, ok := runner.last.Params["cells"].([][]any)
|
||||
if !ok || len(cells) != 1 || len(cells[0]) != 2 {
|
||||
t.Fatalf("cells = %#v", runner.last.Params["cells"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetRangeSetStyleExpandsScalarStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner,
|
||||
"range", "set-style",
|
||||
"--node", "NODE_001",
|
||||
"--sheet-id", "SHEET_001",
|
||||
"--range", "A1:B2",
|
||||
"--bg-color", "#FFF2CC",
|
||||
"--font-weight", "bold",
|
||||
)
|
||||
|
||||
if runner.last.Tool != "update_range" {
|
||||
t.Fatalf("tool = %q, want update_range", runner.last.Tool)
|
||||
}
|
||||
bg, ok := runner.last.Params["backgroundColors"].([][]string)
|
||||
if !ok || len(bg) != 2 || len(bg[0]) != 2 || bg[1][1] != "#FFF2CC" {
|
||||
t.Fatalf("backgroundColors = %#v", runner.last.Params["backgroundColors"])
|
||||
}
|
||||
weights, ok := runner.last.Params["fontWeights"].([][]string)
|
||||
if !ok || weights[0][0] != "bold" {
|
||||
t.Fatalf("fontWeights = %#v", runner.last.Params["fontWeights"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetFilterViewUpdateCriteriaCallsSetCriteria(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner,
|
||||
"filter-view", "update-criteria",
|
||||
"--node", "NODE_001",
|
||||
"--sheet-id", "SHEET_001",
|
||||
"--filter-view-id", "FV_001",
|
||||
"--column", "2",
|
||||
"--filter-criteria", `{"filterType":"values","visibleValues":["销售部"]}`,
|
||||
)
|
||||
|
||||
if runner.last.Tool != "set_filter_view_criteria" {
|
||||
t.Fatalf("tool = %q, want set_filter_view_criteria", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["filterViewId"]; got != "FV_001" {
|
||||
t.Fatalf("filterViewId = %#v", got)
|
||||
}
|
||||
if got := runner.last.Params["column"]; got != 2 {
|
||||
t.Fatalf("column = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCondFormatCreateExpandsCondition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner,
|
||||
"cond-format", "create",
|
||||
"--node", "NODE_001",
|
||||
"--sheet-id", "SHEET_001",
|
||||
"--ranges", `["A1:A10"]`,
|
||||
"--condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`,
|
||||
"--cell-style", `{"backgroundColor":"#FFCDD2"}`,
|
||||
)
|
||||
|
||||
if runner.last.Tool != "create_cond_format" {
|
||||
t.Fatalf("tool = %q, want create_cond_format", runner.last.Tool)
|
||||
}
|
||||
if _, ok := runner.last.Params["numberCondition"]; !ok {
|
||||
t.Fatalf("numberCondition missing from %#v", runner.last.Params)
|
||||
}
|
||||
if _, ok := runner.last.Params["cellStyle"]; !ok {
|
||||
t.Fatalf("cellStyle missing from %#v", runner.last.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCSVCommandsAcceptFileIDAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner,
|
||||
"csv-get",
|
||||
"--file-id", "NODE_001",
|
||||
"--sheet-id", "SHEET_001",
|
||||
"--range", "A1:B2",
|
||||
)
|
||||
|
||||
if runner.last.Tool != "get_range_as_csv" {
|
||||
t.Fatalf("tool = %q, want get_range_as_csv", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
|
||||
t.Fatalf("csv-get nodeId = %#v", got)
|
||||
}
|
||||
|
||||
executeSheetCommand(t, runner,
|
||||
"csv-put",
|
||||
"--file-id", "NODE_002",
|
||||
"--sheet-id", "SHEET_002",
|
||||
"--csv", "a,b\n1,2",
|
||||
"--start-cell", "A1",
|
||||
)
|
||||
|
||||
if runner.last.Tool != "set_range_from_csv" {
|
||||
t.Fatalf("tool = %q, want set_range_from_csv", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["nodeId"]; got != "NODE_002" {
|
||||
t.Fatalf("csv-put nodeId = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceAllowsEmptyReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &sheetCommandRunner{}
|
||||
executeSheetCommand(t, runner,
|
||||
"replace",
|
||||
"--node", "NODE_001",
|
||||
"--sheet-id", "SHEET_001",
|
||||
"--find", "临时",
|
||||
"--replacement", "",
|
||||
)
|
||||
|
||||
if runner.last.Tool != "replace_all" {
|
||||
t.Fatalf("tool = %q, want replace_all", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["replaceText"]; got != "" {
|
||||
t.Fatalf("replaceText = %#v, want empty string", got)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (todoHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newTodoTaskDoneCommand(runner),
|
||||
newTodoTaskGetCommand(runner),
|
||||
newTodoTaskDeleteCommand(runner),
|
||||
newTodoAddAttachmentCommand(runner),
|
||||
)
|
||||
root.AddCommand(task)
|
||||
return root
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newTodoAddAttachmentCommand builds the hardcoded `todo task add-attachment`
|
||||
// leaf. The MCP backend exposes attachment upload as a multi-step orchestration
|
||||
// that the envelope/pipeline layer cannot express (no upload step, local file
|
||||
// IO): init upload credentials -> HTTP PUT the local file -> commit -> add.
|
||||
// wukong implements it in code; this is the open-edition equivalent. It is
|
||||
// wired into the existing todo handler's task group (see todo.go).
|
||||
func newTodoAddAttachmentCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add-attachment",
|
||||
Short: i18n.T("上传待办附件"),
|
||||
Long: i18n.T("上传本地文件作为待办附件(init → 上传 → commit → add 四步)。会真实上传文件,请确认待办存在。"),
|
||||
Example: " dws todo task add-attachment --task-id <taskId> --file-path <filePath>",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID := strings.TrimSpace(firstNonEmptyFlag(cmd, "task-id", "id"))
|
||||
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-path", "file"))
|
||||
if taskID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --task-id")
|
||||
}
|
||||
if filePath == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --file-path")
|
||||
}
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("cannot read file %s: %v", filePath, err))
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s is a directory, not a file", filePath))
|
||||
}
|
||||
fileName := filepath.Base(filePath)
|
||||
fileType := strings.TrimPrefix(filepath.Ext(fileName), ".")
|
||||
fileSize := fi.Size()
|
||||
md5Hex, err := fileMD5Hex(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
|
||||
"todoAttachmentAddRequest": map[string]any{
|
||||
"taskId": taskID,
|
||||
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// 1) init upload credentials
|
||||
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "todo", "init_todo_file_upload", map[string]any{
|
||||
"todoAttachmentInitUploadInfoRequest": map[string]any{
|
||||
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
|
||||
if resourceURL == "" {
|
||||
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
|
||||
}
|
||||
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
|
||||
if resourceURL == "" || uploadKey == "" {
|
||||
return apperrors.NewAPI(fmt.Sprintf("incomplete upload credentials: resourceUrl=%q uploadKey=%q", resourceURL, uploadKey))
|
||||
}
|
||||
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
|
||||
|
||||
// 2) PUT the local file to the resource URL
|
||||
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3) commit upload
|
||||
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "todo", "commit_todo_file_upload", map[string]any{
|
||||
"todoAttachmentCommitUploadInfoRequest": map[string]any{
|
||||
"uploadKey": uploadKey, "fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dentryID := findInt64Deep(commitRes.Response, "dentryId", "dentryID")
|
||||
spaceID := findInt64Deep(commitRes.Response, "spaceId", "spaceID")
|
||||
if dentryID == 0 || spaceID == 0 {
|
||||
return apperrors.NewAPI("uploaded file response missing dentryId or spaceId")
|
||||
}
|
||||
|
||||
// 4) add attachment to the todo
|
||||
addRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
|
||||
"todoAttachmentAddRequest": map[string]any{
|
||||
"taskId": taskID,
|
||||
"attachmentList": []any{map[string]any{
|
||||
"fileId": strconv.FormatInt(dentryID, 10),
|
||||
"fileName": fileName,
|
||||
"fileSize": fileSize,
|
||||
"spaceId": strconv.FormatInt(spaceID, 10),
|
||||
"fileType": fileType,
|
||||
}},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, addRes)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
|
||||
cmd.Flags().String("id", "", i18n.T("--task-id 的别名"))
|
||||
cmd.Flags().String("file-path", "", i18n.T("本地文件路径 (必填)"))
|
||||
cmd.Flags().String("file", "", i18n.T("--file-path 的别名"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func fileMD5Hex(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
|
||||
}
|
||||
defer f.Close()
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("md5 of %s: %v", path, err))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func httpPutLocalFile(ctx context.Context, url string, headers map[string]string, path string, size int64) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
|
||||
}
|
||||
defer f.Close()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, f)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("build PUT request: %v", err))
|
||||
}
|
||||
req.ContentLength = size
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("upload PUT failed: %v", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return apperrors.NewAPI(fmt.Sprintf("upload PUT returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findStringDeep recursively searches a decoded JSON value for the first
|
||||
// non-empty string value under any of the given keys.
|
||||
func findStringDeep(v any, keys ...string) string {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range keys {
|
||||
if s, ok := scalarString(t[k]); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for _, child := range t {
|
||||
if s := findStringDeep(child, keys...); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
if s := findStringDeep(child, keys...); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findFirstInStringArrayDeep recursively finds the first non-empty string in an
|
||||
// array stored under any of the given keys (e.g. "resourceUrls").
|
||||
func findFirstInStringArrayDeep(v any, keys ...string) string {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range keys {
|
||||
if arr, ok := t[k].([]any); ok {
|
||||
for _, e := range arr {
|
||||
if s, ok := scalarString(e); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range t {
|
||||
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findInt64Deep(v any, keys ...string) int64 {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range keys {
|
||||
if n, ok := scalarInt64(t[k]); ok && n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
for _, child := range t {
|
||||
if n := findInt64Deep(child, keys...); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
if n := findInt64Deep(child, keys...); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func findHeadersDeep(v any, keys ...string) map[string]string {
|
||||
out := map[string]string{}
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range keys {
|
||||
if h, ok := t[k].(map[string]any); ok {
|
||||
for name, val := range h {
|
||||
if s, ok := scalarString(val); ok && s != "" {
|
||||
out[name] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
for _, child := range t {
|
||||
if h := findHeadersDeep(child, keys...); len(h) > 0 {
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scalarString(v any) (string, bool) {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s, true
|
||||
case json.Number:
|
||||
return s.String(), true
|
||||
case float64:
|
||||
return strconv.FormatFloat(s, 'f', -1, 64), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func scalarInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return i, true
|
||||
}
|
||||
if f, err := n.Float64(); err == nil {
|
||||
return int64(f), true
|
||||
}
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case string:
|
||||
if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
+297
-1
@@ -14,6 +14,8 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
@@ -64,6 +66,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newWikiSpaceGetCommand(runner),
|
||||
newWikiSpaceListCommand(runner),
|
||||
newWikiSpaceSearchCommand(runner),
|
||||
newWikiSpaceDeleteCommand(runner),
|
||||
)
|
||||
root.AddCommand(space)
|
||||
|
||||
@@ -81,6 +84,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newWikiMemberAddCommand(runner),
|
||||
newWikiMemberUpdateCommand(runner),
|
||||
newWikiMemberListCommand(runner),
|
||||
newWikiMemberRemoveCommand(runner),
|
||||
)
|
||||
root.AddCommand(member)
|
||||
return root
|
||||
@@ -150,6 +154,20 @@ func newWikiSpaceListCommand(runner executor.Runner) *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
params := map[string]any{}
|
||||
if spaceType := wikiFlagOrFallback(cmd, "type"); spaceType != "" {
|
||||
if spaceType == "orgSpace" || spaceType == "mySpace" {
|
||||
driveParams := map[string]any{"spaceType": spaceType}
|
||||
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
|
||||
if n, err := strconv.Atoi(limit); err == nil {
|
||||
driveParams["maxResults"] = n
|
||||
} else {
|
||||
driveParams["maxResults"] = limit
|
||||
}
|
||||
}
|
||||
if pageToken := wikiFlagOrFallback(cmd, "cursor", "page-token"); pageToken != "" {
|
||||
driveParams["nextToken"] = pageToken
|
||||
}
|
||||
return runWikiProductTool(cmd, runner, "drive", "list_spaces", driveParams)
|
||||
}
|
||||
params["wikiSpaceType"] = spaceType
|
||||
}
|
||||
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
|
||||
@@ -211,6 +229,32 @@ func newWikiSpaceSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiSpaceDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除知识库",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmDeletePrompt(cmd, "知识库", workspaceID) {
|
||||
return nil
|
||||
}
|
||||
return runWikiTool(cmd, runner, "delete_wikiSpace", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiMemberAddCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
@@ -313,10 +357,233 @@ func newWikiMemberListCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiMemberRemoveCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除知识库成员",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := wikiRequiredFlagOrFallback(cmd, "users", "user", "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runWikiTool(cmd, runner, "remove_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"userIds": wikiCSV(user),
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addWikiMemberListWorkspaceFlag(cmd)
|
||||
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
addWikiHiddenStringFlag(cmd, "user", "--users 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiNodeListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "列出知识库节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"workspaceId": workspaceID}
|
||||
if folder := wikiFlagOrFallback(cmd, "folder", "node", "parent-id"); folder != "" {
|
||||
params["folderId"] = normalizeDocNodeID(folder)
|
||||
}
|
||||
if limit := wikiIntFlagOrFallback(cmd, "limit", "page-size"); limit > 0 {
|
||||
params["pageSize"] = limit
|
||||
}
|
||||
if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
return runWikiProductTool(cmd, runner, "doc", "list_nodes", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addWikiNodeWorkspaceFlag(cmd)
|
||||
cmd.Flags().String("folder", "", "父节点 nodeId")
|
||||
addWikiHiddenStringFlag(cmd, "node", "--folder 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
|
||||
cmd.Flags().Int("limit", 0, "每页数量")
|
||||
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("page-size")
|
||||
cmd.Flags().String("cursor", "", "分页游标")
|
||||
addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiNodeCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "在知识库中创建节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := wikiRequiredFlag(cmd, "name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"name": name,
|
||||
}
|
||||
if nodeType := wikiFlagOrFallback(cmd, "type"); nodeType != "" {
|
||||
params["type"] = nodeType
|
||||
}
|
||||
if folder := wikiFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
|
||||
params["folderId"] = normalizeDocNodeID(folder)
|
||||
}
|
||||
return runWikiProductTool(cmd, runner, "doc", "create_file", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addWikiNodeWorkspaceFlag(cmd)
|
||||
cmd.Flags().String("name", "", "节点名称 (必填)")
|
||||
cmd.Flags().String("type", "adoc", "节点类型: adoc / asheet / folder / axls")
|
||||
cmd.Flags().String("folder", "", "父节点 nodeId")
|
||||
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiNodeCopyCommand(runner executor.Runner) *cobra.Command {
|
||||
return newWikiNodeTransferCommand(runner, "copy", "copy_document")
|
||||
}
|
||||
|
||||
func newWikiNodeMoveCommand(runner executor.Runner) *cobra.Command {
|
||||
return newWikiNodeTransferCommand(runner, "move", "move_document")
|
||||
}
|
||||
|
||||
func newWikiNodeTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: use + " 知识库节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"nodeId": normalizeDocNodeID(nodeID),
|
||||
"workspaceId": workspaceID,
|
||||
}
|
||||
if folder := wikiFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
|
||||
params["targetFolderId"] = normalizeDocNodeID(folder)
|
||||
}
|
||||
return runWikiProductTool(cmd, runner, "doc", tool, params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addWikiNodeWorkspaceFlag(cmd)
|
||||
addWikiNodeIDFlags(cmd)
|
||||
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
|
||||
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiNodeDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除知识库节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if _, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId"); err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmDeletePrompt(cmd, "知识库节点", nodeID) {
|
||||
return nil
|
||||
}
|
||||
return runWikiProductTool(cmd, runner, "doc", "delete_document", map[string]any{
|
||||
"nodeId": normalizeDocNodeID(nodeID),
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addWikiNodeWorkspaceFlag(cmd)
|
||||
addWikiNodeIDFlags(cmd)
|
||||
cmd.Flags().BoolP("yes", "y", false, "跳过确认直接删除")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWikiNodeSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "在知识库中搜索节点",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := wikiFlagOrFallback(cmd, "query", "keyword")
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"keyword": query,
|
||||
"workspaceIds": []string{workspaceID},
|
||||
}
|
||||
if values, _ := cmd.Flags().GetStringSlice("extensions"); len(values) > 0 {
|
||||
params["extensions"] = splitWikiStringSlice(values)
|
||||
}
|
||||
if limit := wikiIntFlagOrFallback(cmd, "limit"); limit > 0 {
|
||||
params["pageSize"] = limit
|
||||
}
|
||||
if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
return runWikiProductTool(cmd, runner, "doc", "search_documents", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
addWikiNodeWorkspaceFlag(cmd)
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
addWikiHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
|
||||
cmd.Flags().StringSlice("extensions", nil, "按扩展名过滤,逗号分隔")
|
||||
cmd.Flags().Int("limit", 0, "每页数量")
|
||||
cmd.Flags().String("cursor", "", "分页游标")
|
||||
addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runWikiTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
return runWikiProductTool(cmd, runner, "wiki", tool, params)
|
||||
}
|
||||
|
||||
func runWikiProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"wiki",
|
||||
product,
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
@@ -344,6 +611,19 @@ func addWikiMemberListWorkspaceFlag(cmd *cobra.Command) {
|
||||
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
|
||||
}
|
||||
|
||||
func addWikiNodeWorkspaceFlag(cmd *cobra.Command) {
|
||||
cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
|
||||
}
|
||||
|
||||
func addWikiNodeIDFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
|
||||
addWikiHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
|
||||
addWikiHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
|
||||
}
|
||||
|
||||
func addWikiHiddenStringFlag(cmd *cobra.Command, name, usage string) {
|
||||
cmd.Flags().String(name, "", usage)
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
@@ -397,3 +677,19 @@ func wikiCSV(raw string) []string {
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func splitWikiStringSlice(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if item := strings.TrimSpace(part); item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wikiUnsupportedCommand(name string) error {
|
||||
return fmt.Errorf("unsupported wiki command: %s", name)
|
||||
}
|
||||
|
||||
@@ -38,15 +38,19 @@ func addWikiProxyCommands(root *cobra.Command, runner executor.Runner) {
|
||||
newWikiProxyLeaf(runner, "search", wikiProxyTargetSpace, []string{"space", "search"}, wikiProxyOptions{}),
|
||||
newWikiProxyLeaf(runner, "create", wikiProxyTargetSpace, []string{"space", "create"}, wikiProxyOptions{}),
|
||||
newWikiProxyLeaf(runner, "get", wikiProxyTargetSpace, []string{"space", "get"}, wikiProxyOptions{}),
|
||||
newWikiProxyLeaf(runner, "delete", wikiProxyTargetSpace, []string{"space", "delete"}, wikiProxyOptions{}),
|
||||
)
|
||||
|
||||
node := newWikiProxyGroup("node", "知识库节点兼容入口")
|
||||
node.AddCommand(
|
||||
newWikiProxyLeaf(runner, "list", wikiProxyTargetDoc, []string{"list"}, wikiProxyOptions{}),
|
||||
newWikiNodeListCommand(runner),
|
||||
newWikiProxyLeaf(runner, "read", wikiProxyTargetDoc, []string{"read"}, wikiProxyOptions{}),
|
||||
newWikiProxyLeaf(runner, "info", wikiProxyTargetDoc, []string{"info"}, wikiProxyOptions{}),
|
||||
newWikiProxyLeaf(runner, "create", wikiProxyTargetDoc, []string{"create"}, wikiProxyOptions{}),
|
||||
newWikiProxyLeaf(runner, "search", wikiProxyTargetDoc, []string{"search"}, wikiProxyOptions{workspaceToWorkspaceIDs: true}),
|
||||
newWikiNodeCreateCommand(runner),
|
||||
newWikiNodeCopyCommand(runner),
|
||||
newWikiNodeMoveCommand(runner),
|
||||
newWikiNodeDeleteCommand(runner),
|
||||
newWikiNodeSearchCommand(runner),
|
||||
)
|
||||
|
||||
file := newWikiProxyGroup("file", "知识库文件兼容入口")
|
||||
@@ -164,6 +168,7 @@ func newWikiProxySpaceTargetRoot(runner executor.Runner) *cobra.Command {
|
||||
newWikiSpaceGetCommand(runner),
|
||||
newWikiSpaceListCommand(runner),
|
||||
newWikiSpaceSearchCommand(runner),
|
||||
newWikiSpaceDeleteCommand(runner),
|
||||
)
|
||||
root.AddCommand(space)
|
||||
return root
|
||||
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -259,6 +260,94 @@ func TestWikiMemberAddUsesWorkspaceIDAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceListDriveTypesRouteToDrive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &wikiCommandRunner{}
|
||||
cmd := wikiHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"space", "list", "--type", "orgSpace", "--limit", "10"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.CanonicalProduct != "drive" {
|
||||
t.Fatalf("product = %q, want drive", runner.last.CanonicalProduct)
|
||||
}
|
||||
if runner.last.Tool != "list_spaces" {
|
||||
t.Fatalf("tool = %q, want list_spaces", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["spaceType"]; got != "orgSpace" {
|
||||
t.Fatalf("spaceType = %#v, want orgSpace", got)
|
||||
}
|
||||
if got := runner.last.Params["maxResults"]; got != 10 {
|
||||
t.Fatalf("maxResults = %#v, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeSearchRoutesToDoc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &wikiCommandRunner{}
|
||||
cmd := wikiHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"node", "search",
|
||||
"--workspace-id", "WS_001",
|
||||
"--keyword", "方案",
|
||||
"--extensions", "adoc,asheet",
|
||||
"--limit", "5",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "search_documents" {
|
||||
t.Fatalf("invocation = %#v, want doc search_documents", runner.last)
|
||||
}
|
||||
if got := runner.last.Params["keyword"]; got != "方案" {
|
||||
t.Fatalf("keyword = %#v, want 方案", got)
|
||||
}
|
||||
if got := runner.last.Params["workspaceIds"]; !reflect.DeepEqual(got, []string{"WS_001"}) {
|
||||
t.Fatalf("workspaceIds = %#v, want []string{WS_001}", got)
|
||||
}
|
||||
if got := runner.last.Params["extensions"]; !reflect.DeepEqual(got, []string{"adoc", "asheet"}) {
|
||||
t.Fatalf("extensions = %#v, want adoc/asheet", got)
|
||||
}
|
||||
if got := runner.last.Params["pageSize"]; got != 5 {
|
||||
t.Fatalf("pageSize = %#v, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMemberRemoveRoutesToWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &wikiCommandRunner{}
|
||||
cmd := wikiHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"member", "remove", "--workspace-id", "WS_001", "--user", "uid1,uid2"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.CanonicalProduct != "wiki" || runner.last.Tool != "remove_member" {
|
||||
t.Fatalf("invocation = %#v, want wiki remove_member", runner.last)
|
||||
}
|
||||
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
|
||||
t.Fatalf("workspaceId = %#v, want WS_001", got)
|
||||
}
|
||||
users, ok := runner.last.Params["userIds"].([]string)
|
||||
if !ok || strings.Join(users, ",") != "uid1,uid2" {
|
||||
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMemberUpdateAcceptsWukongUsersAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -36,9 +36,11 @@ const (
|
||||
|
||||
// discoveryAPIPath is the path appended to BaseURL when fetching the
|
||||
// MCP server list. Kept as a single constant so the version-coded
|
||||
// segment (".../bamboo") lives in one place and future version bumps
|
||||
// only touch here. See registry_test.go for the matching fixture path.
|
||||
discoveryAPIPath = "/cli/discovery/apis/bamboo"
|
||||
// segment (".../cedar") lives in one place and future version bumps
|
||||
// only touch here. Version codes step by first letter (bamboo -> cedar
|
||||
// -> ...); cedar carries the dws-wukong alignment. See registry_test.go
|
||||
// for the matching fixture path.
|
||||
discoveryAPIPath = "/cli/discovery/apis/cedar"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
|
||||
@@ -262,7 +262,7 @@ func TestFetchServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/cli/discovery/apis/bamboo" {
|
||||
if r.URL.Path != "/cli/discovery/apis/cedar" {
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
payload := ListResponse{
|
||||
@@ -299,7 +299,7 @@ func TestFetchServersFollowsNextCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/cli/discovery/apis/bamboo" {
|
||||
if r.URL.Path != "/cli/discovery/apis/cedar" {
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ package edition
|
||||
// their environment.
|
||||
const DefaultOSSClawType = "openClaw"
|
||||
|
||||
const openAitableHelperEndpoint = "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95"
|
||||
|
||||
// defaultHooks returns the open-source edition defaults.
|
||||
//
|
||||
// MergeHeaders is the only hook that ships with behaviour: it pins the
|
||||
@@ -43,10 +45,16 @@ func defaultHooks() *Hooks {
|
||||
|
||||
func openSupplementServers() []ServerInfo {
|
||||
return []ServerInfo{
|
||||
{
|
||||
ID: "aitable-helper",
|
||||
Name: "AI 多维表(辅助)",
|
||||
Endpoint: openAitableHelperEndpoint,
|
||||
Prefixes: []string{"form", "share_form"},
|
||||
},
|
||||
{
|
||||
ID: "aitable-form",
|
||||
Name: "AI 多维表(表单)",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95",
|
||||
Endpoint: openAitableHelperEndpoint,
|
||||
Prefixes: []string{"form", "share_form"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,3 +34,27 @@ func TestClawTypeUsesOverlayValue(t *testing.T) {
|
||||
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSupplementServersIncludeAitableHelperAlias(t *testing.T) {
|
||||
servers := openSupplementServers()
|
||||
byID := make(map[string]ServerInfo, len(servers))
|
||||
for _, server := range servers {
|
||||
byID[server.ID] = server
|
||||
}
|
||||
|
||||
helper, ok := byID["aitable-helper"]
|
||||
if !ok {
|
||||
t.Fatalf("openSupplementServers() missing aitable-helper: %#v", servers)
|
||||
}
|
||||
if helper.Endpoint != openAitableHelperEndpoint {
|
||||
t.Fatalf("aitable-helper endpoint = %q, want %q", helper.Endpoint, openAitableHelperEndpoint)
|
||||
}
|
||||
|
||||
form, ok := byID["aitable-form"]
|
||||
if !ok {
|
||||
t.Fatalf("openSupplementServers() missing aitable-form: %#v", servers)
|
||||
}
|
||||
if form.Endpoint != openAitableHelperEndpoint {
|
||||
t.Fatalf("aitable-form endpoint = %q, want %q", form.Endpoint, openAitableHelperEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,22 +1,22 @@
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
#
|
||||
# One-command installer for the dws Dev preview on native Windows (PowerShell).
|
||||
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the fork's GitHub Releases.
|
||||
# One-command installer for dws dev on native Windows (PowerShell).
|
||||
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
|
||||
#
|
||||
# Usage:
|
||||
# irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
|
||||
# irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
#
|
||||
# Env (all optional):
|
||||
# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli)
|
||||
# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork)
|
||||
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
|
||||
# DEVAPP_VERSION pin a release tag (default: latest release)
|
||||
# DWS_ARCH architecture override (amd64 or arm64)
|
||||
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
|
||||
# DWS_NO_SKILLS set 1 to skip the dev skill
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "wxianfeng/dingtalk-workspace-cli" }
|
||||
$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
|
||||
$Version = $env:DEVAPP_VERSION
|
||||
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
||||
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
|
||||
@@ -40,15 +40,15 @@ function Get-Arch {
|
||||
}
|
||||
}
|
||||
|
||||
# GitHub's /releases/latest excludes prereleases; read the releases list (newest
|
||||
# first) and take the top tag — the dev preview is published as a prerelease.
|
||||
# Read the releases list (newest first) and take the top tag, so this also works
|
||||
# if a release is ever published as a prerelease (which /releases/latest skips).
|
||||
if (-not $Version) {
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases?per_page=1" `
|
||||
-Headers @{ "User-Agent" = "dws-devapp-installer" } -UseBasicParsing
|
||||
$Version = $rel[0].tag_name
|
||||
} catch {}
|
||||
if (-not $Version) { Die "No release found on $Repo. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION." }
|
||||
if (-not $Version) { Die "No release found on $Repo. Set DEVAPP_VERSION to a published release tag." }
|
||||
}
|
||||
|
||||
$arch = Get-Arch
|
||||
@@ -56,7 +56,7 @@ $tmp = Join-Path $env:TEMP ("dws-dev-" + [System.Guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
||||
|
||||
Write-Host ""
|
||||
Say "dws Dev preview installer (Windows, pre-built binary)"
|
||||
Say "dws dev installer (Windows, pre-built binary)"
|
||||
Say "Repo: $Repo"
|
||||
Say "Version: $Version"
|
||||
Say "Target: windows/$arch"
|
||||
|
||||
+10
-10
@@ -2,21 +2,21 @@
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
#
|
||||
# One-command installer for the dws Dev preview — pre-built binary, no build tools.
|
||||
# Downloads the dev binary + dingtalk-dev skill from the fork's GitHub Releases.
|
||||
# One-command installer for dws dev — pre-built binary, no build tools.
|
||||
# Downloads the dev binary + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
|
||||
# Requires only curl + tar (no go / make / git).
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
#
|
||||
# Env (all optional):
|
||||
# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli)
|
||||
# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork)
|
||||
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
|
||||
# DEVAPP_VERSION pin a release tag (default: latest release)
|
||||
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
|
||||
# DWS_NO_SKILLS set 1 to skip the dev skill
|
||||
set -eu
|
||||
|
||||
DEVAPP_REPO="${DEVAPP_REPO:-wxianfeng/dingtalk-workspace-cli}"
|
||||
DEVAPP_REPO="${DEVAPP_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
DEVAPP_VERSION="${DEVAPP_VERSION:-}"
|
||||
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
||||
@@ -45,8 +45,8 @@ detect_arch() {
|
||||
esac
|
||||
}
|
||||
|
||||
# GitHub's /releases/latest excludes prereleases, so read the releases list
|
||||
# (newest first) and take the top tag — the dev preview is published as a prerelease.
|
||||
# Read the releases list (newest first) and take the top tag, so this also works
|
||||
# if a release is ever published as a prerelease (which /releases/latest skips).
|
||||
# Prefer `gh` CLI (authenticated, 5 000 req/h) over raw curl (60 req/h, easily rate-limited).
|
||||
resolve_version() {
|
||||
[ -n "$DEVAPP_VERSION" ] && return 0
|
||||
@@ -68,7 +68,7 @@ resolve_version() {
|
||||
|
||||
DEVAPP_VERSION="$(grep -m1 '"tag_name"' "$_tmpfile" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
|
||||
rm -f "$_tmpfile"
|
||||
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION."
|
||||
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Set DEVAPP_VERSION to a published release tag."
|
||||
}
|
||||
|
||||
install_skill() {
|
||||
@@ -104,7 +104,7 @@ main() {
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
printf '\n'
|
||||
say "dws Dev preview installer (pre-built binary)"
|
||||
say "dws dev installer (pre-built binary)"
|
||||
say "Repo: ${DEVAPP_REPO}"
|
||||
say "Version: ${DEVAPP_VERSION}"
|
||||
say "Target: ${os}/${arch}"
|
||||
|
||||
@@ -27,6 +27,8 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
$BinName = "dws"
|
||||
# GitHub "latest release" URL; Resolve-LatestVersion follows its redirect to get the tag.
|
||||
$LatestUrl = "https://github.com/$Repo/releases/latest"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
|
||||
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
|
||||
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
|
||||
|
||||
@@ -62,23 +62,95 @@ fi
|
||||
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
|
||||
echo " Gitee release id = ${release_id}"
|
||||
|
||||
# ── Upload each artifact as a release attachment ──────────────────────────────
|
||||
# ── Mirror each artifact, verifying content so re-runs self-heal ─────────────
|
||||
# Pull the current attachment list (name + attach id + download url) so we can,
|
||||
# per file:
|
||||
# • skip it when it is already on Gitee, unique, AND byte-identical,
|
||||
# • REPLACE it when present but stale (different bytes) — e.g. the darwin
|
||||
# binaries are re-signed and so differ between GitHub and a prior mirror
|
||||
# run, which breaks install.sh's checksums.txt verification on macOS,
|
||||
# • DEDUP it when the same name has >1 attachment — a prior run that failed to
|
||||
# delete (see below) left the old copy *and* an extra upload; Gitee then
|
||||
# serves the OLDER one by name, so the stale binary wins. We delete every
|
||||
# copy and upload one fresh.
|
||||
# • upload it when missing.
|
||||
# This brings the Gitee release into byte-for-byte agreement with $DIST_DIR,
|
||||
# which the caller fills from the GitHub release whose checksums.txt is what
|
||||
# install.sh verifies against.
|
||||
#
|
||||
# We list attachments via the dedicated /attach_files endpoint, NOT the release
|
||||
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
|
||||
# id, so DELETE /attach_files/{id} was previously called with an empty id and
|
||||
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
|
||||
assets_map="$(curl -fsSL "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
for a in rows:
|
||||
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
|
||||
if n and i!="":
|
||||
print("%s\t%s\t%s" % (n, i, u))
|
||||
except Exception:
|
||||
pass' 2>/dev/null || true)"
|
||||
|
||||
sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}';
|
||||
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'; fi
|
||||
}
|
||||
|
||||
gitee_attach() { # upload file $1; success when the response carries a download url
|
||||
printf '%s' "$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" -F "file=@${1}" 2>/dev/null || true)" \
|
||||
| grep -q '"browser_download_url"'
|
||||
}
|
||||
|
||||
gitee_delete() { # delete attachment by id $1
|
||||
curl -fsSL -X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
uploaded=0
|
||||
replaced=0
|
||||
skipped=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
echo " ⬆ ${fn}"
|
||||
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "file=@${f}" 2>/dev/null || true)"
|
||||
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
|
||||
uploaded=$((uploaded + 1))
|
||||
else
|
||||
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
|
||||
local_sha="$(sha256_of "$f")"
|
||||
# Every attach id currently carrying this name (may be >1 from a botched run).
|
||||
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
|
||||
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
|
||||
count="$(printf '%s' "$ids" | grep -c . || true)"
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo " ⬆ ${fn} (new)"
|
||||
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$count" -eq 1 ]; then
|
||||
gitee_sha="$(curl -fsSL "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
if [ "$gitee_sha" = "$local_sha" ]; then
|
||||
echo " ✓ ${fn} already correct on Gitee — skip"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
echo " ↻ ${fn} differs on Gitee (stale) — deleting + re-uploading"
|
||||
else
|
||||
echo " ↻ ${fn} has ${count} copies on Gitee (dup) — deleting all + re-uploading one"
|
||||
fi
|
||||
|
||||
# Delete every copy, then upload exactly one fresh, correct file.
|
||||
printf '%s\n' "$ids" | while read -r aid; do
|
||||
[ -n "$aid" ] && gitee_delete "$aid"
|
||||
done
|
||||
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; fi
|
||||
done
|
||||
|
||||
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
|
||||
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
|
||||
if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
|
||||
echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
|
||||
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
|
||||
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
| 资源 | URI 格式 |
|
||||
|------|----------|
|
||||
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
|
||||
| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
|
||||
| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
|
||||
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
|
||||
|
||||
> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
|
||||
> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
|
||||
> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
|
||||
> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
|
||||
> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
|
||||
>
|
||||
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
|
||||
|
||||
## 命令索引表
|
||||
@@ -19,10 +25,9 @@
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
|
||||
| `base search` | 搜索 Base;不传关键词时列出最近 Base | — | 可选 `--query`;不传时走 list_bases |
|
||||
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
|
||||
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
|
||||
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
|
||||
| `base copy` | 复制 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 目标必须是 `dws doc folder create/list` 返回的文档文件夹 `nodeId`;不要传钉盘数字 `dentryId`,也不要用手工新建 base/table 代替 |
|
||||
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
|
||||
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
|
||||
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
|
||||
|
||||
@@ -31,8 +36,8 @@
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
|
||||
| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 可选;不传时创建空字段表 |
|
||||
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
|
||||
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
|
||||
| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
|
||||
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
|
||||
|
||||
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
|
||||
@@ -44,6 +49,30 @@
|
||||
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
|
||||
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
|
||||
|
||||
#### 搜索字段选项
|
||||
```
|
||||
Usage:
|
||||
dws aitable field search-options [flags]
|
||||
Example:
|
||||
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID>
|
||||
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --keyword 已完成
|
||||
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --limit 100
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
|
||||
--keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
|
||||
--limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
|
||||
--table-id string Table ID (必填)
|
||||
```
|
||||
|
||||
仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
|
||||
|
||||
适用场景:
|
||||
- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
|
||||
- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
|
||||
|
||||
> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
|
||||
|
||||
### record (记录管理)
|
||||
|
||||
| 命令 | 用途 | 必读 reference | 路由提醒 |
|
||||
@@ -51,36 +80,66 @@
|
||||
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
|
||||
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
|
||||
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
|
||||
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组;同一组值批量更新也用此命令展开 records |
|
||||
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组 |
|
||||
| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
|
||||
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
|
||||
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
|
||||
| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
|
||||
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
|
||||
| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
|
||||
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
|
||||
| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
|
||||
|
||||
### view (视图管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
|
||||
| `view list` | 列出全部视图(`view get` 不传 `--view-ids` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价;只需视图列表时优先 |
|
||||
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;可选 `--name` 指定视图名称(未传时自动生成)、`--config` 传初始配置 JSON |
|
||||
| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 |
|
||||
| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
|
||||
| `view get <attr>` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
|
||||
| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
|
||||
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
|
||||
| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
|
||||
| `view update <attr>` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
|
||||
| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
|
||||
| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
|
||||
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
|
||||
|
||||
> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。
|
||||
> **优先用 `view get <attr>` / `view update <attr>` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
|
||||
|
||||
> **view update --config 支持的 key 白名单**(传入其他 key 会报错):
|
||||
> - `visibleFieldIds` — 视图可见字段列表及顺序(首列字段必须保留在第一位)
|
||||
> - `filter` — 筛选规则**数组**(⚠️ 注意是数组 `[...]`,不是对象 `{...}`)
|
||||
> - `sort` — 排序规则**数组**
|
||||
> - `group` — 分组规则**数组**
|
||||
> - `fieldWidths` — 列宽映射(仅 Grid 视图有效)
|
||||
>
|
||||
> **filter/sort/group 必须传数组格式**,不要和 `record query --filters`(对象格式)混淆。详见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md) § view update 章节。
|
||||
> CLI 会自动容错(对象→数组 wrap),但建议直接使用正确格式。
|
||||
>
|
||||
> 不支持 `formInfo`、`requiredFields`、`conditionalRules` 等 FormDesigner 高级配置,这些 key 会被服务端忽略。
|
||||
> **属性按 attr 分类,决定该读哪份子文档**:
|
||||
> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
|
||||
> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
|
||||
|
||||
### 表单视图 → 详见 [aitable-form.md](./aitable/aitable-form.md)
|
||||
> **`view update --config` 支持的 9 个 key**:
|
||||
> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
|
||||
> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
|
||||
|
||||
悟空命令面不暴露 `form` 命令组;表单按 `viewType=FormDesigner` 的视图处理,创建/查看/更新/删除都使用 `view` 命令。
|
||||
### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
|
||||
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
|
||||
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
|
||||
| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
|
||||
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
|
||||
| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
|
||||
| `form questions create/delete` | 题目管理(=field create/delete) | — | 详情见子文档 |
|
||||
| `form share get/update` | 表单分享配置 | — | 详情见子文档 |
|
||||
|
||||
> **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。
|
||||
|
||||
### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
|
||||
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
|
||||
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
|
||||
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
|
||||
|
||||
> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
|
||||
|
||||
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
|
||||
|
||||
@@ -88,6 +147,7 @@
|
||||
|------|------|
|
||||
| `dashboard get/create/update/delete` | 仪表盘管理 |
|
||||
| `dashboard config-example` | 查看仪表盘配置模板 |
|
||||
| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
|
||||
| `chart get/create/update/delete` | 图表管理 |
|
||||
| `chart widgets-example` | 查看图表 widgets 配置模板 |
|
||||
|
||||
@@ -111,15 +171,162 @@
|
||||
|------|------|----------|
|
||||
| `template search` | 搜索模板 | `--query` |
|
||||
|
||||
## 评测执行硬约束
|
||||
### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md)
|
||||
|
||||
- 多轮任务必须执行到用户要求的最后一步;不要只回复"现在开始/下一步执行",也不要在创建 base/table/field 后提前结束。
|
||||
- 每个写操作后用 `base get`、`table get`、`field get`、`record query` 或对应 `view get/list` 读回验证真实 ID 与结果。
|
||||
- 字段批量 JSON 推荐 `fieldName`;CLI 兼容 `name`,但 skill 生成时不要主动使用 `name`。字段类型统一用小写/规范值,如 `text`、`number`、`singleSelect`、`attachment`。
|
||||
- 成员/负责人字段类型使用 `user`,不要生成 `member`。
|
||||
- 复制 AI 表格必须调用 `dws aitable base copy --base-id <BASE_ID> --target-folder-id <FOLDER_NODE_ID> --format json`。目标目录必须是 `dws doc folder create` 或 `dws doc list` 返回的文档文件夹 `nodeId`;不要传 `drive list` 返回的数字 `dentryId`,不要用新建 base/table 的手工方式代替 `base copy`。
|
||||
- 用户未指定目标文件夹时:先 `dws doc info --node <BASE_ID> --format json` 取 `workspaceId`,再 `dws doc folder create --workspace <WORKSPACE_ID> --name "AI表格副本" --format json` 创建目标文件夹,最后把返回的 `nodeId` 传给 `base copy`。
|
||||
- 导入 Excel/CSV 前先用 `find` 或 `ls` 确认真实文件路径;遇到中文文件名乱码或路径不匹配时,重新查找实际文件,不要停在解释阶段。
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 |
|
||||
| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 |
|
||||
| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 |
|
||||
| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 |
|
||||
| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 |
|
||||
| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
|
||||
| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
|
||||
|
||||
> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
|
||||
> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
|
||||
> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
|
||||
|
||||
### section (文件夹与节点管理)
|
||||
|
||||
> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
|
||||
> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
|
||||
|
||||
#### 创建文件夹
|
||||
```
|
||||
Usage:
|
||||
dws aitable section create [flags]
|
||||
Example:
|
||||
dws aitable section create --base-id <BASE_ID> --name 我的文件夹
|
||||
dws aitable section create --base-id <BASE_ID> --name 子文件夹 --parent-section-id <SECTION_ID> --index 0
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--name string 文件夹名称 (必填)
|
||||
--parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
|
||||
--index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
|
||||
```
|
||||
|
||||
返回 `data.sectionId` 与 `data.name`。
|
||||
|
||||
#### 重命名文件夹
|
||||
```
|
||||
Usage:
|
||||
dws aitable section rename [flags]
|
||||
Example:
|
||||
dws aitable section rename --base-id <BASE_ID> --section-id <SECTION_ID> --new-name 新名称
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--section-id string 目标文件夹 ID (必填)
|
||||
--new-name string 新的文件夹名称 (必填)
|
||||
```
|
||||
|
||||
#### 删除文件夹
|
||||
```
|
||||
Usage:
|
||||
dws aitable section delete [flags]
|
||||
Example:
|
||||
dws aitable section delete --base-id <BASE_ID> --section-id <SECTION_ID>
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--section-id string 目标文件夹 ID (必填)
|
||||
```
|
||||
|
||||
> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
|
||||
|
||||
#### 调整文件夹顺序
|
||||
```
|
||||
Usage:
|
||||
dws aitable section reorder [flags]
|
||||
Example:
|
||||
dws aitable section reorder --base-id <BASE_ID> --section-id <SECTION_ID> --target-index 0
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--section-id string 目标文件夹 ID (必填)
|
||||
--target-index int 目标位置(0-based)(必填)
|
||||
```
|
||||
|
||||
> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
|
||||
|
||||
#### 列出空文件夹
|
||||
```
|
||||
Usage:
|
||||
dws aitable section list-empty [flags]
|
||||
Example:
|
||||
dws aitable section list-empty --base-id <BASE_ID>
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
```
|
||||
|
||||
返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
|
||||
|
||||
#### 列出全部节点
|
||||
```
|
||||
Usage:
|
||||
dws aitable section list-nodes [flags]
|
||||
Example:
|
||||
dws aitable section list-nodes --base-id <BASE_ID>
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
```
|
||||
|
||||
返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
|
||||
|
||||
> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
|
||||
|
||||
#### 移动节点
|
||||
```
|
||||
Usage:
|
||||
dws aitable section move-node [flags]
|
||||
Example:
|
||||
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id <SECTION_ID>
|
||||
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id "" --target-index 0
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
|
||||
--new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
|
||||
--target-index int Base 内节点的全局位置(0-based);不传则不调整
|
||||
```
|
||||
|
||||
> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
|
||||
> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
|
||||
|
||||
## 复杂操作
|
||||
|
||||
### 仪表盘 / 图表(建议顺序)
|
||||
|
||||
```bash
|
||||
# 1) 先看配置模板(JSONC)
|
||||
dws aitable dashboard config-example --format json
|
||||
dws aitable chart widgets-example --format json
|
||||
|
||||
# 2) 先拿 dashboard,再拿 chart 详情
|
||||
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
|
||||
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
|
||||
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
|
||||
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
|
||||
|
||||
### 导出数据(两阶段轮询)
|
||||
|
||||
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
|
||||
|
||||
```bash
|
||||
# 第一步:创建任务(按 scope 传必要参数)
|
||||
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
|
||||
|
||||
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
|
||||
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
|
||||
```
|
||||
|
||||
参数约束
|
||||
|
||||
- `scope=all`:只需 `base-id`
|
||||
- `scope=table`:必须 `table-id`
|
||||
- `scope=view`:必须同时 `table-id + view-id`
|
||||
|
||||
## 意图判断
|
||||
|
||||
@@ -127,14 +334,14 @@
|
||||
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
|
||||
- 详情 → `base get`
|
||||
- 创建 → `base create`
|
||||
- 复制 → `base copy`,必须调用 `dws aitable base copy --base-id <BASE_ID> --target-folder-id <FOLDER_NODE_ID> --format json`;若无目标文件夹,先 `doc info --node <BASE_ID>` 取 `workspaceId`,再 `doc folder create --workspace <WORKSPACE_ID>` 创建文档文件夹作为目标。服务端返回 `Invalid target folder ID` 时,改用 `doc folder create` 新建目标文件夹后重试一次;不要手工重建副本。
|
||||
- 修改 → `base update`
|
||||
- 删除 → `base delete`
|
||||
|
||||
用户说"数据表/子表/table":
|
||||
- 查看 → `table get`
|
||||
- 创建 → `table create`
|
||||
- 重命名 → `table update`
|
||||
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
|
||||
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
|
||||
- 删除 → `table delete`
|
||||
|
||||
用户说"字段/列/column":
|
||||
@@ -145,19 +352,35 @@
|
||||
|
||||
用户说"记录/行/数据/row":
|
||||
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
|
||||
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
|
||||
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
|
||||
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
|
||||
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
|
||||
- **批量更新同一字段值**(统一标记/统一改值) → `record update --records '[{"recordId":"rec1","cells":{...}},{"recordId":"rec2","cells":{...}}]'`
|
||||
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
|
||||
- 删除 → `record delete`
|
||||
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
|
||||
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
|
||||
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
|
||||
|
||||
用户说"视图/view":
|
||||
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
|
||||
- 看某个视图详情 → `view get --view-ids <ID>`
|
||||
- 创建 → `view create`
|
||||
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
|
||||
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
|
||||
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
- 删除 → `view delete`
|
||||
|
||||
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
|
||||
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
|
||||
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
|
||||
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
|
||||
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
|
||||
|
||||
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
|
||||
|
||||
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
|
||||
@@ -166,16 +389,33 @@
|
||||
|
||||
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
|
||||
|
||||
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md),使用 `view create --view-type FormDesigner`
|
||||
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
|
||||
|
||||
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
|
||||
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
|
||||
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
|
||||
- 启用流程 → `workflow enable`
|
||||
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
|
||||
- **新建 / 修改 / 删除流程**:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
|
||||
|
||||
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
|
||||
|
||||
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
|
||||
|
||||
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
|
||||
|
||||
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
|
||||
|
||||
用户说"模板" → `template search`
|
||||
|
||||
用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
|
||||
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
|
||||
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
|
||||
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
|
||||
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
|
||||
- 删角色 → `advperm role-delete --yes`
|
||||
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
|
||||
|
||||
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
|
||||
|
||||
**关键区分**: base=表格文件, table=数据表, field=列, record=行
|
||||
@@ -190,7 +430,7 @@ dws aitable base search --query "项目" --format json
|
||||
dws aitable base get --base-id <BASE_ID> --format json
|
||||
|
||||
# 3. 获取表结构 — 提取 fieldId
|
||||
dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID> --format json
|
||||
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
|
||||
|
||||
# 4. 查询记录
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
|
||||
@@ -206,7 +446,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|------|-------------|------|
|
||||
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
|
||||
| `base create` | `baseId` | 后续命令 + 文档 URI |
|
||||
| `base get` | `tables[].tableId` | --table-id |
|
||||
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
|
||||
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
|
||||
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
|
||||
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
|
||||
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# advperm — 高级权限管理
|
||||
|
||||
控制 Base 的高级权限总开关,并管理自定义角色(增删改查 + 子角色权限规则)。
|
||||
适用场景:"如何控制谁能看/改 Base 数据"、"开启/关闭高级权限"、"新建/修改/删除角色"、"按字段或行配置权限"。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `advperm enable` | 开启 Base 高级权限总开关 |
|
||||
| `advperm disable` | 关闭 Base 高级权限总开关(高危) |
|
||||
| `advperm role-list` | 列出 Base 下全部角色 |
|
||||
| `advperm role-get` | 获取单角色完整配置 |
|
||||
| `advperm role-create` | 创建自定义角色 |
|
||||
| `advperm role-update` | 增量更新自定义角色(PATCH 语义) |
|
||||
| `advperm role-delete` | 删除自定义角色(不可逆) |
|
||||
|
||||
> 所有子命令的 `--base-id` 必填,可用隐藏别名 `--base`。
|
||||
|
||||
## 命令详情
|
||||
|
||||
### advperm enable — 开启高级权限
|
||||
|
||||
```bash
|
||||
dws aitable advperm enable --base-id BASE_ID --format json
|
||||
```
|
||||
|
||||
返回 `{baseId, enabled: true}`。
|
||||
|
||||
只有开启后角色配置才会真正限制成员的可访问范围;关闭状态下角色配置仍可读但不生效。
|
||||
|
||||
### advperm disable — 关闭高级权限(高危)
|
||||
|
||||
```bash
|
||||
dws aitable advperm disable --base-id BASE_ID --yes --format json
|
||||
```
|
||||
|
||||
返回 `{baseId, enabled: false}`。关闭后所有角色配置即刻失效,全员回退到默认权限。涉及多人协作或敏感数据务必和用户二次确认,建议先 `role-list` 留底。
|
||||
|
||||
### advperm role-list — 列出全部角色
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-list --base-id BASE_ID --format json
|
||||
```
|
||||
|
||||
返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"defaultRole": { "mode": 0 },
|
||||
"roles": [
|
||||
{
|
||||
"roleId": "10685308981",
|
||||
"name": "可查看角色",
|
||||
"roleType": "custom",
|
||||
"system": false,
|
||||
"subRoles": [
|
||||
{
|
||||
"authLevel": "read",
|
||||
"targetId": "HMEaRQ4",
|
||||
"targetType": "sheet",
|
||||
"config": { "actions": 268435455 },
|
||||
"display": {
|
||||
"authLevelLabel": "仅查看",
|
||||
"targetTypeLabel": "数据表",
|
||||
"permissionScopeNote": "...",
|
||||
"actionsLabels": ["新增视图", "删除视图", "修改视图"],
|
||||
"actionsNote": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键字段:
|
||||
|
||||
- `roleType`:`custom`(自定义) / `system_editor` / `system_reader` / `5000`(owner) / `4000`(manager)。
|
||||
- `system`:boolean,true 表示系统角色(不可删)。
|
||||
- `subRoles[].display.*`:服务端返回的人类可读标签,可直接拼接给用户阅读,无需自行映射枚举。
|
||||
- 不返回角色成员列表;如需"成员-角色"映射请去 AI 表格 Web 端。
|
||||
- 新建 Base 默认 `enabled=false`,开启后只有 `owner` / `manager` 两个 meta 角色;`system_editor` / `system_reader` 需要在 Web UI 给成员授权"可编辑/可查看"后才会被服务端自动生成。
|
||||
|
||||
`role-list` / `role-get` 不需要管理员权限,普通成员也可读。
|
||||
|
||||
### advperm role-get — 获取单角色配置
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
|
||||
```
|
||||
|
||||
返回结构同 `role-list` 中单个 role 对象(含完整 `subRoles[].config` 字段/行级规则与 `display.*` 标签)。
|
||||
|
||||
### advperm role-create — 创建自定义角色
|
||||
|
||||
```bash
|
||||
# 仅指定 name,子角色由服务端按默认(none)填充
|
||||
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" --format json
|
||||
|
||||
# 创建时即指定 sub-roles(推荐——避免再走一次 role-update)
|
||||
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
|
||||
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"read"}]' --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|:---:|------|
|
||||
| `--name` | ✅ | 角色名称 |
|
||||
| `--role-type` | | 角色类型字符串(留空由服务端决定默认值,如 `custom`) |
|
||||
| `--flow-type` | | 流程类型字符串(按业务需要) |
|
||||
| `--sub-roles` | | JSON 数组:`[{targetId, targetType, authLevel, appId?, config?}]`,详见下方"sub-roles 子字段"段 |
|
||||
|
||||
返回新建角色的完整配置(同 `role-get` 出参格式,含自动生成的 default subRoles)。
|
||||
系统角色无法通过本命令创建。
|
||||
|
||||
### advperm role-update — 增量更新自定义角色(PATCH 语义)
|
||||
|
||||
```bash
|
||||
# 只改名
|
||||
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
|
||||
|
||||
# 只改 sheet 子角色 authLevel,name 不传保持不变
|
||||
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
|
||||
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"edit-own"}]'
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|:---:|------|
|
||||
| `--role-id` | ✅ | 目标自定义角色 ID(数字 long 字符串) |
|
||||
| `--name` | | 新角色名称;不传不修改 |
|
||||
| `--role-type` / `--flow-type` | | 可选 |
|
||||
| `--sub-roles` | | JSON 数组,**PATCH 合并语义**:按 `(targetId, targetType)` 合并到现有 subRoles,入参中的 sub 整体替换该 sub,**入参未提及的 sub 保留不变**(无需先调 `role-get` 自行 merge) |
|
||||
|
||||
**系统角色禁止更新**(包括 owner / manager / system_editor / system_reader)。
|
||||
|
||||
### sub-roles 子字段
|
||||
|
||||
每个 sub-role 描述「角色对某个权限目标的访问粒度」:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `targetId` | string | 目标资源 ID(数据表 → `tableId`;仪表盘 → `dashboardId`;应用 → `appId`) |
|
||||
| `targetType` | string | `sheet` / `dashboard` / `app` |
|
||||
| `authLevel` | string | `manage` / `edit-own` / `edit-custom-field` / `edit-field-range` / `read` / `none` |
|
||||
| `appId` | string(可选) | 仅 `targetType=app` 时使用 |
|
||||
| `config` | object(可选) | 字段/行级细化规则;含 `actions`(位图)/ `rows` / `cells`。结构与 `role-get` 出参 `subRoles[].config` 对齐 |
|
||||
|
||||
### advperm role-delete — 删除自定义角色(不可逆)
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
|
||||
```
|
||||
|
||||
要求同时满足:
|
||||
|
||||
1. 该 Base 已开启高级权限(`role-list` 返回 `enabled=true`)。
|
||||
2. 当前 dws 登录用户是该 Base 的管理员/Owner。
|
||||
3. `--role-id` 是 `role-list` 返回的数字 long 字符串(如 `"10685308981"`),且对应角色 `system=false`。
|
||||
|
||||
不可逆,删前先 `role-get` 留底。
|
||||
|
||||
## 能力边界
|
||||
|
||||
| 能力 | 状态 |
|
||||
|------|------|
|
||||
| 开/关高级权限 | ✅ 需管理员 |
|
||||
| 列出 / 读取角色 | ✅ 普通成员也可读 |
|
||||
| 创建自定义角色 | ✅ 需管理员 |
|
||||
| 增量修改角色(PATCH 语义,不清空未传字段) | ✅ 需管理员 |
|
||||
| 删除自定义角色 | ✅ 需管理员 |
|
||||
| 修改/删除系统角色 | ❌ 服务端禁止;只能在 AI 表格 Web 端操作 |
|
||||
| 角色 ↔ 成员绑定 | ❌ CLI 暂不支持,需在 AI 表格 Web 端 → Base 设置 → 高级权限 → 角色管理面板手动完成 |
|
||||
|
||||
## 错误码速查
|
||||
|
||||
| 场景 | code | type | message |
|
||||
|------|------|------|---------|
|
||||
| advperm 关闭时调用写接口(如 `role-delete` / `role-create` / `role-update`) | `ADVANCED_PERMISSION_DISABLED` | `USER_ERROR` | `Advanced permission is disabled for base <BASE>, please enable it via setAdvancedPermission before managing roles` |
|
||||
| 非管理员调用 `enable` / `disable` / `role-create` / `role-update` / `role-delete` | `401` | `AUTH_ERROR` | `the current user must be a manager (administrator) of this base to manage roles or advanced permission` |
|
||||
| 删除/更新系统角色(`system=true`) | `600` | `USER_ERROR` | `Illegal argument` |
|
||||
| 操作不存在的数字 roleId(get/update/delete) | `600` | `USER_ERROR` | `Illegal argument` |
|
||||
| 传非数字 roleId(如 `owner` / `manager`) | `INVALID_PARAMS` | `INPUT_ERROR` | `roleId is required` |
|
||||
| `role-create` 缺 `--name` | `INVALID_PARAMS` | `INPUT_ERROR` | `name is required` |
|
||||
| `--sub-roles` JSON 不是数组 / 解析失败 | (CLI 层拦截) | — | `--sub-roles 解析失败 ...` / `--sub-roles 必须是 JSON 数组` |
|
||||
| `--base-id` 无法解析 | `INVALID_BASE_ID` | `INPUT_ERROR` | `baseId cannot be resolved to docId` |
|
||||
|
||||
> `600 / Illegal argument` 同时覆盖"操作系统角色"和"操作不存在 roleId"两种情况。拿到 `600` 时先 `role-list` 自查目标 roleId 是否存在、是否 `system=true`,再据此引导用户。
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 排查"成员看不到某些字段/记录"
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-list --base-id BASE_ID --format json
|
||||
# 若 enabled=false:高级权限未开,所有规则不生效,与用户确认是否需要 enable
|
||||
|
||||
dws aitable advperm enable --base-id BASE_ID --format json
|
||||
dws aitable advperm role-list --base-id BASE_ID --format json
|
||||
# 看 roles[] 里有哪些自定义角色
|
||||
|
||||
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
|
||||
# 检查 subRoles[].config 中的字段/行级权限规则
|
||||
```
|
||||
|
||||
### 新建一个"市场可读"角色
|
||||
|
||||
```bash
|
||||
# 1. 确保高级权限已开
|
||||
dws aitable advperm enable --base-id BASE_ID --format json
|
||||
|
||||
# 2. 拿目标 sheet 的 tableId
|
||||
dws aitable table get --base-id BASE_ID --format json
|
||||
|
||||
# 3. 创建角色 + 指定 sheet 子角色 authLevel=read
|
||||
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
|
||||
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"read"}]' \
|
||||
--format json
|
||||
# → 返回新角色完整配置,含 roleId,记下后续 patch / delete 使用
|
||||
```
|
||||
|
||||
### 升级角色权限(read → edit-own),保留其他配置
|
||||
|
||||
```bash
|
||||
# 只传 sub-roles,name 等其他字段保持不变(PATCH 语义)
|
||||
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
|
||||
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"edit-own"}]' \
|
||||
--format json
|
||||
```
|
||||
|
||||
### 改角色名(不影响权限规则)
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
|
||||
```
|
||||
|
||||
### 清理废弃角色
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-list --base-id BASE_ID --format json
|
||||
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
|
||||
```
|
||||
|
||||
### 关闭高级权限(恢复全员可见)
|
||||
|
||||
```bash
|
||||
dws aitable advperm role-list --base-id BASE_ID --format json > /tmp/roles-backup.json
|
||||
dws aitable advperm disable --base-id BASE_ID --yes --format json
|
||||
```
|
||||
@@ -1,6 +1,8 @@
|
||||
# attachment — 附件上传
|
||||
|
||||
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
|
||||
>
|
||||
> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
|
||||
|
||||
## 准备附件上传
|
||||
|
||||
@@ -38,8 +40,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size 204800 --format json
|
||||
# → 返回 uploadUrl、fileToken
|
||||
|
||||
# 2. PUT 上传(Content-Type 留空)
|
||||
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @report.pdf
|
||||
# 2. PUT 上传(Content-Type 必须是文件的具体 MIME type)
|
||||
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
|
||||
|
||||
# 3. 写入记录
|
||||
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|
||||
@@ -4,35 +4,30 @@
|
||||
|
||||
| 字段类型 | 可写 | 正确方式 |
|
||||
|----------|------|----------|
|
||||
| 文本/数字/日期/单选/多选/复选框/URL | 是 | `dws aitable record create` / `dws aitable record update` |
|
||||
| 附件 | 是,但需先上传 | 先 `dws aitable attachment upload` 取 `uploadUrl/fileToken`,PUT 后把 `fileToken` 写入记录 |
|
||||
| 创建人/修改人/创建时间/修改时间 | 否 | 系统字段,只读 |
|
||||
| 公式/查找引用 | 否 | 由系统计算,只读 |
|
||||
| AI 字段 | 否 | 由 AI 自动计算,只读 |
|
||||
| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update |
|
||||
| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) |
|
||||
| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 |
|
||||
| 公式/查找引用 | ❌ | 只读,由系统计算 |
|
||||
| AI 字段 | ❌ | 只读,由 AI 自动计算 |
|
||||
|
||||
## 2. 查询执行契约
|
||||
|
||||
1. 优先用 `dws aitable record query --filters` 在服务端过滤,不要先拉全量再在上下文里手动筛选。
|
||||
2. 返回 `has_more=true` 时不能做全局结论,数据可能不完整。
|
||||
3. 查询前先用 `dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID>` 获取真实 fieldId,不要猜字段 ID。
|
||||
4. 只需要部分字段时,用 `dws aitable record query --field-ids fld1,fld2` 降低响应体积。
|
||||
5. 已知 recordId 时,用 `dws aitable record get --record-ids rec1,rec2`,不要构造无意义 filters。
|
||||
1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
|
||||
2. **has_more=true 时不能做全局结论** — 数据可能不完整
|
||||
3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
|
||||
4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
|
||||
5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
|
||||
|
||||
## 3. 任务选路
|
||||
|
||||
| 用户诉求 | 优先方案 | 不要误走 |
|
||||
|---------|----------|----------|
|
||||
| 查看几条数据 | `dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>` | 不要默认 `--all` |
|
||||
| 全量拉取/统计 | `dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --all` | 不要手动循环 cursor |
|
||||
| 全量导出 | `dws aitable export data --base-id <BASE_ID> --scope all --format excel` | 不要 `--all` 拉全量再写文件 |
|
||||
| 文件级导入 | `dws aitable import upload --base-id <BASE_ID> --file-name data.xlsx --file-size <字节数>` + `dws aitable import data --import-id <ID>` | 不要手动解析 xlsx 再逐条写入 |
|
||||
| 批量写入多条不同数据 | `dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> --records '[{"cells":{"<FIELD_ID>":"值"}}]'` | 不要一次超过 100 条 |
|
||||
| 批量给多条记录写同一组值 | `dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> --records '[{"recordId":"rec1","cells":{"<FIELD_ID>":"值"}},{"recordId":"rec2","cells":{"<FIELD_ID>":"值"}}]'` | 不要使用隐藏兼容命令 |
|
||||
| 附件上传 | `dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size <字节数>` + PUT + `record create/update` | 不要用钉盘 drive 上传 |
|
||||
| 调整字段顺序 | `dws aitable view update --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --config '{"visibleFieldIds":["fld1","fld2"]}'` | 没有 `field reorder` 命令 |
|
||||
| 查看视图列表 | `dws aitable view list --base-id <BASE_ID> --table-id <TABLE_ID>` | 不需要用 `view get --view-ids` |
|
||||
| 创建收集表/问卷 | `dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --view-type FormDesigner --name "表单名"` | 不要使用隐藏兼容命令 |
|
||||
| 仪表盘/图表 | 先 `dashboard config-example` / `chart widgets-example`,再 create/update | 不要猜 config 结构 |
|
||||
| 查看几条数据 | `record query` | 不要用 `--all` |
|
||||
| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
|
||||
| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
|
||||
| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
|
||||
| 附件/图片上传 | `attachment upload` 获取 fileToken → `record create/update` 用 fileToken 写入 | **严禁直接传图片 URL 到附件字段**(服务端同步下载会超时) |
|
||||
| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
|
||||
|
||||
## 4. 创建/修改后回读确认
|
||||
|
||||
@@ -40,34 +35,12 @@
|
||||
|
||||
| 写操作 | 建议回读命令 | 确认内容 |
|
||||
|--------|-------------|----------|
|
||||
| `dws aitable base create` | `dws aitable base get --base-id <BASE_ID>` | base 名称、tables 列表 |
|
||||
| `dws aitable table create` | `dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID>` | 表名、字段列表是否符合预期 |
|
||||
| `dws aitable field create` | `dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID>` | 新字段是否出现在字段列表中 |
|
||||
| `dws aitable record create/update` | `dws aitable record get --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>` | 写入值是否正确 |
|
||||
| `dws aitable view update` | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | `visibleFieldIds` 顺序是否正确 |
|
||||
| `dws aitable view create/update` | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | 表单视图名称、描述和配置 |
|
||||
| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
|
||||
| `field create` | `table get --table-ids <tableId>` | 新字段是否出现在字段列表中 |
|
||||
| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
|
||||
|
||||
## 5. 导入导出与异步任务
|
||||
## 5. AI 字段注意事项
|
||||
|
||||
- `export data` 的 `--format` 是导出格式,不要在此命令上追加全局 `--format json`。
|
||||
- 创建导出任务:
|
||||
```bash
|
||||
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> \
|
||||
--format excel --timeout-ms 1000
|
||||
```
|
||||
- 续等已有导出任务:
|
||||
```bash
|
||||
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
|
||||
```
|
||||
- 导入本地文件:
|
||||
```bash
|
||||
dws aitable import upload --base-id <BASE_ID> --file-name data.xlsx --file-size <字节数> --format json
|
||||
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @data.xlsx
|
||||
dws aitable import data --import-id <IMPORT_ID> --format json
|
||||
```
|
||||
|
||||
## 6. AI 字段注意事项
|
||||
|
||||
- AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用,纯文本 prompt 会被后端拒绝。
|
||||
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用。
|
||||
- `outputType` 必须与字段类型一致,例如 `outputType=text` 配 `--type text`。
|
||||
- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
|
||||
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用
|
||||
- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`)
|
||||
|
||||
@@ -86,11 +86,16 @@
|
||||
{"fldDateId": "2026-03-15T09:00+08:00"}
|
||||
```
|
||||
|
||||
**读取**:RFC3339 字符串
|
||||
**读取**:RFC3339 字符串(带时区)
|
||||
```json
|
||||
{"fldDateId": "2026-03-15T09:00:00+08:00"}
|
||||
```
|
||||
|
||||
**过滤**(`record query --filters`):日期字段**只能用日期专用操作符** `date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`,比较值用日期字符串(如 `"2026-03-15"`)。
|
||||
- ❌ 通用 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对日期字段无效,会静默返回 0 条;
|
||||
- ❌ 不支持区间 `date_between` 与相对 `from_now`(CLI 会直接拒绝),范围查询用 `not_before` + `not_after` 组合。
|
||||
- 详见 [aitable-filter-sort.md](./aitable-filter-sort.md) §日期字段过滤。
|
||||
|
||||
---
|
||||
|
||||
### currency(货币)
|
||||
@@ -236,15 +241,14 @@
|
||||
|
||||
### attachment(附件)
|
||||
|
||||
**写入**:对象数组,支持 `fileToken` 或 `url` 形式
|
||||
**写入**:对象数组,**必须使用 `fileToken`**
|
||||
|
||||
```json
|
||||
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
|
||||
{"fldAttachId": [{"url": "https://example.com/file.pdf"}]}
|
||||
```
|
||||
|
||||
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。
|
||||
> URL 形式是 best-effort 异步转存,不保证立即可用。
|
||||
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 上传文件获取 `fileToken`,再将 `fileToken` 写入 cells。**
|
||||
> ❌ **严禁直接传 `{"url": "https://..."}` 形式写入附件/图片字段** — 服务端会同步下载图片,10 条记录即触发 TIMEOUT_ERROR 超时。
|
||||
> 写入会**整体覆盖**原附件列表,不是追加。
|
||||
|
||||
**读取**:对象数组(含下载链接、文件名、大小)
|
||||
@@ -335,7 +339,7 @@
|
||||
|------|----------|
|
||||
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
|
||||
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
|
||||
| attachment 直接传文件路径 | 必须先 upload 获取 fileToken |
|
||||
| attachment 直接传文件路径或图片 URL | 必须先 `attachment upload` 获取 fileToken,再用 fileToken 写入(直传 URL 会超时) |
|
||||
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
|
||||
| group 字段用 `openConversationId` | 用 `cid` |
|
||||
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
|
||||
|
||||
@@ -27,17 +27,18 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
|
||||
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
|
||||
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
|
||||
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
|
||||
|
||||
## chart 子命令
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
|
||||
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
|
||||
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
|
||||
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
|
||||
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
|
||||
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
|
||||
|
||||
## 配置获取流程
|
||||
|
||||
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置;同时必须传 `--layout` 指定图表位置和尺寸,例如 `--layout '{"x":0,"y":0,"w":6,"h":4}'`。
|
||||
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
| 错误现象 / summary | 原因 | 恢复动作 |
|
||||
|-------------------|------|---------|
|
||||
| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --base-id <BASE_ID> --task-id <taskId>` 轮询直到完成 |
|
||||
| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --task-id <taskId>` 轮询直到完成 |
|
||||
| 导入文件格式错误 | 不支持的文件格式或文件损坏 | 确认文件为 .xlsx 格式且未加密 |
|
||||
|
||||
## 3. 重试策略
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
|
||||
|
||||
> ⚠️ **`export data` 的 `--format` 是导出格式**:需要导出 xlsx/附件时写 `--format excel` / `excel_and_attachment`。不要在这个命令上追加全局 `--format json`。
|
||||
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
|
||||
|
||||
```bash
|
||||
# 第一步:创建任务(按 scope 传必要参数)
|
||||
# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json!
|
||||
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
|
||||
|
||||
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
- `field create --name --type --config` 中 config 单独传 JSON 字符串
|
||||
- `field update --config` 只传 config 部分
|
||||
- 不需要 config 的类型(如 text、checkbox、attachment)可省略 config 字段
|
||||
- 成员/负责人字段类型使用 `user`,不要使用 `member`;字段类型不要写 `Text`/`Number`,统一写规范值 `text`/`number`
|
||||
|
||||
## 2. 字段类型速查
|
||||
|
||||
@@ -235,6 +234,5 @@ AI 字段不使用 config,而使用独立的 `--ai-config` 参数。详见 [ai
|
||||
| options 更新时只传新增项 | 全量覆盖,旧选项全部丢失 |
|
||||
| formula 字段尝试写入值 | 只读字段,record create/update 会报错 |
|
||||
| linkedTableId 传表名而非 ID | 必须传 tableId(如 `tblXXX`),不接受表名 |
|
||||
| 成员字段使用 `member` | 不支持;人员/负责人/成员字段统一使用 `user` |
|
||||
| progress 值写入 50 表示 50% | 实际应写入 0.5(range 0~1) |
|
||||
| rating 值超出 max | 写入会报错 |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# filters & sort — 筛选排序语法参考
|
||||
|
||||
> 视图(view)配置的 filter/sort/group **整体写入**请优先用 `view update filter` / `view update sort` / `view update group` 子命令,详见 [aitable-view-config.md](./aitable-view-config.md)。本文件聚焦于 `record query --filters` 与 view config filter 的语法和差异。
|
||||
|
||||
## filters 结构规范
|
||||
|
||||
### 强制规则
|
||||
@@ -49,11 +51,34 @@ CLI 同时兼容两种子条件写法(推荐格式 A):
|
||||
| `exist` / `un_exist` | 有值 / 为空 | `["fieldId"]`(无需第二项) |
|
||||
| `any_of` / `none_of` / `all_of` | 包含任一 / 不包含任一 / 全包含(多选字段) | `["fieldId", "optionName"]` |
|
||||
| `date_eq` / `before` / `after` | 日期等于 / 早于 / 晚于 | `["fieldId", "dateStr"]` |
|
||||
| `not_before` / `not_after` | 不早于 / 不晚于 | `["fieldId", "dateStr"]` |
|
||||
| `from_now` | 从现在起 N 天内 | `["fieldId", "天数"]` |
|
||||
| `date_between` | 日期区间 | `["fieldId", "[startTs, endTs]"]` |
|
||||
| `not_before` / `not_after` | 不早于(≥) / 不晚于(≤) | `["fieldId", "2026-05-22"]` |
|
||||
|
||||
> **操作符拼写必须严格匹配上表**,CLI 会在调用前校验,错误拼写会被拒绝。
|
||||
>
|
||||
> **没有 `date_between`(区间)操作符**,也**不支持 `from_now`**——date 字段不支持区间/相对过滤,传了会被 CLI 拒绝。范围查询用 `not_before` + `not_after` 组合,见下方专节。
|
||||
|
||||
### 日期字段过滤(date / 创建时间 / 修改时间)
|
||||
|
||||
日期类字段的过滤规则与其它字段**不同**,是线上反馈最高频的踩坑点。**经集成测试实测**确认的规则:
|
||||
|
||||
1. **只能用日期专用操作符**:`date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`(与前端筛选 UI 的「等于 / 早于 / 晚于 / 早于或等于 / 晚于或等于 / 不为空 / 为空」一一对应)。
|
||||
2. **比较值用日期字符串**,如 `"2026-05-22"`(也接受 RFC3339 / 毫秒时间戳,内部统一转成毫秒比较)。读取返回的是带时区 RFC3339(如 `"2026-05-22T00:00:00+08:00"`)。
|
||||
3. **通用操作符 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对 date 字段无效**——无论传 ISO 字符串还是毫秒时间戳,都会**静默返回 0 条**。这是后端 date 字段的比较规则,不是 bug,CLI 也无法在本地拦截(不知道字段类型),务必用对操作符。
|
||||
4. **没有区间操作符 `date_between`**,也**不支持 `from_now`(相对天数)**——均会静默返回 0 条,CLI 已直接拒绝。范围查询用 `not_before`(≥起点)+ `not_after`(≤终点)两个条件 `and` 组合。
|
||||
|
||||
| 需求 | 操作符 | 示例 operands |
|
||||
|------|--------|--------------|
|
||||
| 等于某天 | `date_eq` | `["fldDate", "2026-05-22"]` |
|
||||
| 早于 / 晚于(不含当天) | `before` / `after` | `["fldDate", "2026-05-22"]` |
|
||||
| 不早于(≥) / 不晚于(≤) | `not_before` / `not_after` | `["fldDate", "2026-05-22"]` |
|
||||
| 有值 / 为空 | `exist` / `un_exist` | `["fldDate"]` |
|
||||
|
||||
**日期区间查询(替代 between)**——查 `2026-05-01 ~ 2026-05-31`(含端点):
|
||||
|
||||
```bash
|
||||
dws aitable record query --base-id X --table-id Y \
|
||||
--filters '{"operator":"and","operands":[{"operator":"not_before","operands":["fldDate","2026-05-01"]},{"operator":"not_after","operands":["fldDate","2026-05-31"]}]}'
|
||||
```
|
||||
|
||||
### 常见错误拼写(CLI 会自动提示纠正)
|
||||
|
||||
|
||||
@@ -1,37 +1,120 @@
|
||||
# 表单视图 — 使用 view(FormDesigner)
|
||||
# form — 表单管理
|
||||
|
||||
悟空命令面不暴露独立表单命令组。表单在 AI 表格里按 `viewType=FormDesigner` 的视图处理,所有生成命令都走 `view` 和 `field`。
|
||||
## 命令一览
|
||||
|
||||
## 命令路线
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `form list` | 列出数据表下所有表单视图 |
|
||||
| `form get` | 按 viewId 取单个表单详情(list_form_views + viewIds 过滤) |
|
||||
| `form create` | 创建表单视图(等价于 `view create --view-type FormDesigner`) |
|
||||
| `form update` | 更新表单标题或描述 |
|
||||
| `form delete` | 删除表单视图(不可逆) |
|
||||
| `form field list` | 列出表单可见字段 |
|
||||
| `form field update` | 更新字段必填/描述 |
|
||||
| `form field hide` | 在表单中隐藏/显示字段(不影响底层数据表字段) |
|
||||
| `form share get` | 获取分享配置 |
|
||||
| `form share update` | 开启/关闭分享 |
|
||||
| `form questions create` | 添加题目(等价于 `field create`,命令位置上的别名) |
|
||||
| `form questions delete` | 删除题目(等价于 `field delete`,命令位置上的别名) |
|
||||
|
||||
| 诉求 | 使用命令 | 说明 |
|
||||
|------|----------|------|
|
||||
| 列出表单视图 | `dws aitable view list --base-id <BASE_ID> --table-id <TABLE_ID>` | 从返回视图中过滤 `viewType=FormDesigner` |
|
||||
| 查看表单视图 | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | 按 viewId 获取 |
|
||||
| 创建表单视图 | `dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --view-type FormDesigner --name "表单名"` | 返回 `viewId` |
|
||||
| 更新表单视图 | `dws aitable view update --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --name "新名称"` | 描述用 `--desc` JSON |
|
||||
| 删除表单视图 | `dws aitable view delete --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --yes` | 不可逆 |
|
||||
| 添加题目 | `dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> --fields '[...]'` | 题目本质是字段 |
|
||||
| 删除题目 | `dws aitable field delete --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --yes` | 不可逆 |
|
||||
|
||||
## 创建工作流
|
||||
## 建议操作顺序
|
||||
|
||||
```bash
|
||||
# 1. 创建表单视图
|
||||
dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
|
||||
--view-type FormDesigner --name "员工信息收集" --format json
|
||||
# 1) 列出数据表下的表单视图
|
||||
dws aitable form list --base-id BASE_ID --table-id TABLE_ID --format json
|
||||
|
||||
# 2. 添加题目字段
|
||||
dws aitable field create --base-id BASE_ID --table-id TABLE_ID \
|
||||
--fields '[{"fieldName":"姓名","type":"text"},{"fieldName":"邮箱","type":"text"}]' --format json
|
||||
# 2) 查看单个表单详情
|
||||
dws aitable form get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
|
||||
|
||||
# 3. 回读表单视图
|
||||
dws aitable view get --base-id BASE_ID --table-id TABLE_ID --view-ids VIEW_ID --format json
|
||||
# 3) 查看表单字段配置
|
||||
dws aitable form field list --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
|
||||
|
||||
# 4) 查看分享配置
|
||||
dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
|
||||
```
|
||||
|
||||
## 注意
|
||||
## 要点
|
||||
|
||||
- 不要生成隐藏兼容的独立表单命令;它不属于悟空对齐的公开命令面。
|
||||
- `VIEW_ID` 来自 `view create` 返回。
|
||||
- `FIELD_ID` 来自 `field create` 或 `field get` 返回。
|
||||
- 字段必填、字段隐藏、分享开关等表单高级配置没有公开的悟空命令入口;不要用隐藏兼容命令替代。
|
||||
- **创建表单**有两种等价方式:
|
||||
- `form create --name "表单名"`(推荐,语义清晰)
|
||||
- `view create --view-type FormDesigner --name "表单名"`(底层一致)
|
||||
- `form update` 支持 `--title` 与 `--name` 两个等价参数;至少需传一项
|
||||
- `form field update` 必须传 `--required` 或 `--field-description` 至少一项
|
||||
- `form field hide` 仅控制字段在表单中的可见性,不影响底层数据表字段
|
||||
- **题目管理**与字段管理本质相同(题目 = 表格字段):
|
||||
- `form questions create` 与 `field create` 入参完全一致(`--fields` JSON 或 `--name --type`)
|
||||
- `form questions delete` 与 `field delete` 入参完全一致(必传 `--field-id`)
|
||||
- 设置必填要在 create 后用 `form field update --required true` 单独调一次
|
||||
|
||||
## form 子命令
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
|
||||
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
|
||||
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
|
||||
| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
|
||||
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
|
||||
|
||||
## form field 子命令
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `form field list` | 列出表单字段 | `--base-id` `--table-id` `--view-id` | 返回 fieldId/name/type/required/hidden/description(hidden=true 的字段不在此返回) |
|
||||
| `form field update` | 更新表单字段 | `--base-id` `--table-id` `--view-id` `--field-id` | `--required` 或 `--field-description` 至少一项 |
|
||||
| `form field hide` | 切换字段隐藏 | `--base-id` `--table-id` `--view-id` `--field-id` `--hidden` | `--hidden true` 隐藏 / `--hidden false` 显示 |
|
||||
|
||||
## form questions 子命令
|
||||
|
||||
`form questions create/delete` 与 `field create/delete` 入参、行为完全一致,只是命令位置归属于 `form` 命令组,方便从表单视角操作题目。
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `form questions create` | 添加题目 | `--base-id` `--table-id` + (`--fields` 或 `--name --type`) | 入参与 `field create` 完全一致 |
|
||||
| `form questions delete` | 删除题目 | `--base-id` `--table-id` `--field-id` `--yes` | 入参与 `field delete` 完全一致;不可逆;批量需多次调用 |
|
||||
|
||||
## form share 子命令
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `form share get` | 获取分享配置 | `--base-id` `--table-id` `--view-id` | 返回 enabled/status/shareFormUuid |
|
||||
| `form share update` | 开启/关闭分享 | `--base-id` `--table-id` `--view-id` `--enabled` | `--enabled true` 开启 / `--enabled false` 关闭。注意:UI 上"发布并分享"按钮是另一概念,本命令只切换内部 enabled 标志,开启后需在 UI 刷新页面才会看到分享面板 |
|
||||
|
||||
## 完整工作流示例
|
||||
|
||||
> **占位符约定**:
|
||||
> - `BASE_ID` 来自 `dws aitable base list` / `base search` 返回的 `data.bases[].baseId`
|
||||
> - `TABLE_ID` 来自 `dws aitable base get --base-id BASE_ID` 返回的 `data.tables[].tableId`
|
||||
> - `VIEW_ID` 来自步骤 1 `form create` 返回的 `data.viewId`
|
||||
> - `FIELD_ID` 来自步骤 2 `form questions create` 返回的 `data.results[].fieldId`
|
||||
|
||||
```bash
|
||||
# 1) 创建表单 → 取返回的 data.viewId 作为 VIEW_ID
|
||||
dws aitable form create --base-id BASE_ID --table-id TABLE_ID --name "员工信息收集" --format json
|
||||
|
||||
# 2) 添加题目 → 取返回的 data.results[].fieldId 作为 FIELD_ID
|
||||
dws aitable form questions create --base-id BASE_ID --table-id TABLE_ID \
|
||||
--fields '[{"fieldName":"姓名","type":"text"},{"fieldName":"邮箱","type":"text"}]' --format json
|
||||
|
||||
# 3) 配置表单标题与描述
|
||||
dws aitable form update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
|
||||
--title "员工信息收集" --description "请填写您的基本信息" --format json
|
||||
|
||||
# 4) 设置题目必填(FIELD_ID 来自步骤 2)
|
||||
dws aitable form field update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
|
||||
--field-id FIELD_ID --required true --format json
|
||||
|
||||
# 5) 隐藏不需要的题目
|
||||
dws aitable form field hide --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
|
||||
--field-id FIELD_ID --hidden true --format json
|
||||
|
||||
# 6) 开启分享(注意:开启后需 UI 刷新页面才会看到分享面板)
|
||||
dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
|
||||
--enabled true --format json
|
||||
```
|
||||
|
||||
## 返回结构补充
|
||||
|
||||
- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
|
||||
- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
|
||||
- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 主键文档管理
|
||||
|
||||
## 适用场景
|
||||
|
||||
当需要为 AI 表格中的记录创建或查询关联的主键文档时使用。主键文档是 primaryDoc 类型字段对应的钉钉在线文档,可通过 `dws doc` 进行内容读写。
|
||||
|
||||
## 命令
|
||||
|
||||
### 查询主键文档
|
||||
|
||||
```bash
|
||||
dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID
|
||||
```
|
||||
|
||||
**参数:**
|
||||
- `--base-id`(必填):Base ID
|
||||
- `--table-id`(必填):Table ID
|
||||
- `--record-id`(必填):Record ID
|
||||
|
||||
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
|
||||
|
||||
### 创建主键文档
|
||||
|
||||
```bash
|
||||
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
|
||||
```
|
||||
|
||||
**参数:**
|
||||
- `--base-id`(必填):Base ID
|
||||
- `--table-id`(必填):Table ID
|
||||
- `--field-id`(必填):主键字段 ID,必须是 primaryDoc 类型(通过 `dws aitable table get` 查看字段类型)
|
||||
- `--record-id`(必填):Record ID
|
||||
|
||||
**返回:** `data.nodeId` — 创建或已存在的主键文档 nodeId。
|
||||
|
||||
**幂等性:** 若该记录已有主键文档,直接返回已有文档的 nodeId,不会重复创建。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `fieldId` 必须是 primaryDoc 类型,否则返回 `INVALID_FIELD_TYPE` 错误
|
||||
- 传入不存在的 `recordId` 会返回 `RECORD_NOT_FOUND` 错误
|
||||
- 创建后可通过 `dws doc update --node <nodeId>` 写入文档内容,或 `dws doc read --node <nodeId>` 读取
|
||||
|
||||
## 典型工作流
|
||||
|
||||
```bash
|
||||
# 1. 查询表结构,拿到 primaryDoc 字段的 fieldId
|
||||
dws aitable table get --base-id BASE_ID --table-ids TABLE_ID
|
||||
|
||||
# 2. 为某条记录创建主键文档
|
||||
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
|
||||
|
||||
# 3. 拿到返回的 nodeId,用 dws doc 写入内容
|
||||
dws doc update --node <data.nodeId> --content "# 项目方案\n\n文档正文内容..."
|
||||
```
|
||||
@@ -27,12 +27,13 @@ Flags:
|
||||
| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) |
|
||||
| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 |
|
||||
| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 |
|
||||
| 附件/图片字段直传 URL | ❌ 严禁 `{"url":"https://..."}` — 会触发 TIMEOUT_ERROR。必须先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。详见 [aitable-attachment.md](./aitable-attachment.md) |
|
||||
|
||||
## 正确流程
|
||||
|
||||
```bash
|
||||
# 先获取 fieldId
|
||||
dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID> --format json
|
||||
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
|
||||
# 从返回中提取 fieldId(如 fldABC123)
|
||||
|
||||
# 再用 fieldId 写入记录
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 行记录变更历史(record history-list)
|
||||
|
||||
按 recordId 查询单条记录的全部变更历史,用于审计、回溯字段变更、定位操作人。
|
||||
|
||||
## 命令
|
||||
|
||||
```
|
||||
dws aitable record history-list \
|
||||
--base-id BASE_ID --table-id TABLE_ID --record-id REC_ID \
|
||||
[--offset N] [--limit M]
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
|
||||
| `--table-id` | 所属 Table ID(必填) |
|
||||
| `--record-id` | 目标记录 ID(必填,单条;不支持批量) |
|
||||
| `--offset` | 分页偏移量,默认 0 |
|
||||
| `--limit` | 每页返回数量,范围 [1, 50],默认 20 |
|
||||
|
||||
## 返回结构
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"histories": [
|
||||
{
|
||||
"type": "field_change", // 变更类型
|
||||
"action": "update", // 操作动作: create / update / delete
|
||||
"newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
|
||||
"oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
|
||||
"operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
|
||||
"typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
|
||||
"version": 7 // 版本号(单调递增)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
|
||||
|
||||
## 字段含义速查
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
|
||||
| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
|
||||
| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
|
||||
| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
|
||||
|
||||
## 典型用法
|
||||
|
||||
### 1. 看一条记录被改过几次
|
||||
|
||||
```bash
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
|
||||
| jq '.data.histories[] | {version, action, operateTime}'
|
||||
```
|
||||
|
||||
### 2. 翻页拉全量历史
|
||||
|
||||
```bash
|
||||
# 第 1 页(最新 20 条)
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 0
|
||||
|
||||
# 第 2 页
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 50
|
||||
```
|
||||
|
||||
`limit` 上限 50,需要更多请增加 `offset` 翻页。
|
||||
|
||||
### 3. 回溯某字段最近一次值
|
||||
|
||||
```bash
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
|
||||
| jq '[.data.histories[] | select(.action == "update")][0].oldValue'
|
||||
```
|
||||
|
||||
### 4. 找出删除事件(如果存在 delete history)
|
||||
|
||||
```bash
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
|
||||
| jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 一次只能查一条 record;如需批量审计多条记录请循环调用。
|
||||
- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
|
||||
- 历史保留时长由 server 决定,过老的记录可能不再返回。
|
||||
|
||||
## 与其他 record 命令的关系
|
||||
|
||||
- 想看记录"现在长什么样" → `record query` / `record get`
|
||||
- 想看记录"过去长什么样、什么时候改的" → `record history-list`(本命令)
|
||||
- 想看"这张表整体改过什么" → 当前 CLI 不支持表级 history;只能逐 record 查
|
||||
@@ -0,0 +1,47 @@
|
||||
# 行命名规则枚举键(recordNameKey)映射
|
||||
|
||||
`dws aitable table update --record-name-key <枚举键>` 用于设置数据表的"行命名规则"——卡片/详情页里"行"的展示别名。**取值是固定枚举,不是字段 ID**;传非法值服务端返回 `INVALID_RECORD_NAME_KEY`。
|
||||
|
||||
## 中文 → 枚举键(按 UI 下拉顺序)
|
||||
|
||||
| 用户说 | --record-name-key | 用户说 | --record-name-key |
|
||||
|---|---|---|---|
|
||||
| 记录 | `ji_lu`(默认) | 项目 | `project` |
|
||||
| 任务 | `task` | 事件 | `event` |
|
||||
| 请求 | `request` | 活动 | `campaign` |
|
||||
| 目标 | `objective` | 交付物 | `deliverable` |
|
||||
| 资产 | `asset` | 客户 | `customer` |
|
||||
| 订单 | `order` | 联系人 | `contact` |
|
||||
| 物料/物品 | `item` | 问题 | `question` 或 `issue` |
|
||||
| 工单 | `ticket` | 候选人 | `candidate` |
|
||||
| 商机/机会 | `opportunity` | 会议 | `meeting` |
|
||||
| 成员 | `member` | OKR | `okr` |
|
||||
|
||||
## 其他常用键(按场景分组)
|
||||
|
||||
- **业务流程**:`approval` / `application` / `case` / `decision` / `delivery` / `payment` / `purchase_order` / `quote` / `release`
|
||||
- **HR / 财务**:`employee` / `expense` / `budget` / `invoice`
|
||||
- **产品 / 研发**:`feature` / `feedback` / `idea` / `bug` / `requirement` / `risk` / `sprint` / `story` / `subtask` / `epic`
|
||||
- **CRM**:`account` / `lead` / `prospect` / `deal`
|
||||
- **运营 / 支持**:`note` / `report` / `topic` / `session` / `service`
|
||||
- **资源 / 通用**:`file` / `document` / `product` / `team` / `user` / `vendor` / `key_result` / `metric`
|
||||
|
||||
完整集合较大(共 273 个),服务端校验;以上未列出的合法键也可直接传(如 `goal` / `okr` / `pillar` / `phase` / `milestone` 等)。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 用户说"把这张表的行叫'任务'吧" → 传 task
|
||||
dws aitable table update --base-id BASE --table-id TBL --record-name-key task
|
||||
|
||||
# 用户说"换成项目" → 传 project
|
||||
dws aitable table update --base-id BASE --table-id TBL --record-name-key project
|
||||
|
||||
# 用户说"恢复成默认(记录)" → 传 ji_lu
|
||||
dws aitable table update --base-id BASE --table-id TBL --record-name-key ji_lu
|
||||
```
|
||||
|
||||
## 注意
|
||||
|
||||
- recordNameKey **不会在 `table get` 响应里回显**(`get_tables` DTO 设计上不暴露该字段);写入是否成功以 `table update` 的 set response 是否回填 `recordNameKey` 字段为准。
|
||||
- 中文别名是 server 内置 i18n,UI 显示用户对应的国际化文案,CLI 必须传英文枚举键。
|
||||
@@ -72,3 +72,49 @@ dws aitable record query --base-id X --table-id Y --all --cursor "上次返回
|
||||
- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"`
|
||||
- 不加 `--field-ids` 拉全字段 → 大表响应体积过大
|
||||
- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤
|
||||
|
||||
## record query-empty — 找空行
|
||||
|
||||
`record query-empty` 是与 `record query` 平行的独立子命令,专门按表内顺序扫描出"完全没填用户字段"的空行。
|
||||
|
||||
```bash
|
||||
dws aitable record query-empty --base-id BASE_ID --table-id TABLE_ID
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` / `--base` | 必填 |
|
||||
| `--table-id` | 必填 |
|
||||
| `--limit` | 单次**扫描预算**(不是返回数);范围 [1, 100],默认 100 |
|
||||
| `--cursor` | 分页游标。响应中 `nextCursor` 非空 → 用它翻页继续扫;nextCursor 为空(或不存在)→ 已扫完整表 |
|
||||
|
||||
返回结构:
|
||||
|
||||
```jsonc
|
||||
{ "data": { "records": [...], "nextCursor": "..." } }
|
||||
```
|
||||
|
||||
### 关键语义
|
||||
|
||||
1. **`--limit` 是扫描预算不是返回数**:可能扫了 100 条但全部非空,本页 `records: []`。
|
||||
2. **本页空 records ≠ 全表无空行**:必须看 `nextCursor`,nextCursor 还在就要继续翻。
|
||||
3. **空行定义**:除系统字段(recordId / 创建人 / 创建时间 / 修改人 / 修改时间)外,所有 cell 都是 null、空字符串、空集合或空 Map。一般是用户在 UI 上"插入空行"产生的。
|
||||
|
||||
### 典型用法
|
||||
|
||||
```bash
|
||||
# 扫一页,看本页有没有空行
|
||||
dws aitable record query-empty --base-id BASE --table-id TBL
|
||||
|
||||
# 翻页
|
||||
dws aitable record query-empty --base-id BASE --table-id TBL --cursor <上次的nextCursor>
|
||||
|
||||
# 把整表扫完(手动循环 cursor)
|
||||
NC=""
|
||||
while : ; do
|
||||
R=$(dws aitable record query-empty --base-id BASE --table-id TBL ${NC:+--cursor "$NC"} --format json)
|
||||
echo "$R" | jq '.data.records[] | .recordId'
|
||||
NC=$(echo "$R" | jq -r '.data.nextCursor // empty')
|
||||
[ -z "$NC" ] && break
|
||||
done
|
||||
```
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 行记录分享链接(record share-url)
|
||||
|
||||
按 recordId 批量获取记录的分享链接,把某行单独发给同事查看。
|
||||
|
||||
## 命令
|
||||
|
||||
```
|
||||
dws aitable record share-url \
|
||||
--base-id BASE_ID --table-id TABLE_ID \
|
||||
--record-ids rec1,rec2,rec3 \
|
||||
[--view-id VIEW_ID]
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
|
||||
| `--table-id` | 所属 Table ID(必填) |
|
||||
| `--record-ids` | 目标 Record ID 列表,CSV 逗号分隔,**单次最多 20 条**(必填) |
|
||||
| `--view-id` | 视图 ID(可选)。带上后链接打开会落在该视图上下文里 |
|
||||
|
||||
## 返回结构
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"items": [
|
||||
{ "recordId": "rec1", "shareUrl": "https://..." },
|
||||
{ "recordId": "rec2", "shareUrl": "https://..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`shareUrl` 为 null 表示该条获取失败(不影响其他条目)。
|
||||
|
||||
## 典型用法
|
||||
|
||||
```bash
|
||||
# 一次拿一条记录的链接
|
||||
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1
|
||||
|
||||
# 批量拿,配合 jq 过滤出 url
|
||||
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1,rec2,rec3 --format json \
|
||||
| jq '.data.items[] | {recordId, shareUrl}'
|
||||
|
||||
# 带视图上下文(链接打开时落在指定视图)
|
||||
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1 --view-id viw_VIP
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **单次最多 20 条**,超出请客户端拆批。
|
||||
- 该链接是分享链接(不是源文档链接),打开后看到的是该 record 的只读详情页。
|
||||
- 取消单条分享 / 关闭整表分享当前 CLI 不支持,需要在 AI 表格 Web 端操作。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 行记录 Upsert(record upsert)
|
||||
|
||||
按 `recordId` 是否存在,自动把入参拆分到 update 链路或 create 链路:批次混合"已存在改 + 新出现建"时用,省掉客户端按 ID 分批的逻辑。
|
||||
|
||||
## 命令
|
||||
|
||||
```
|
||||
dws aitable record upsert \
|
||||
--base-id BASE_ID --table-id TABLE_ID \
|
||||
--records '[{"recordId":"<可选>","cells":{...}}, ...]'
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` | 必填(可用 `--base` 别名) |
|
||||
| `--table-id` | 必填 |
|
||||
| `--records` | 待 upsert 的记录 JSON 数组,**单次最多 100 条**(必填)|
|
||||
| `--records-file` | 从文件读入(命令行 JSON 太长时用),与 `--records` 互斥优先级更高 |
|
||||
|
||||
## --records 结构
|
||||
|
||||
每项 JSON:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"recordId": "rec1", // 可选;带 → update,缺省 → create
|
||||
"cells": { // 必填;key 是 fieldId,value 按字段类型
|
||||
"fldTitleId": "新标题",
|
||||
"fldNumberId": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`cells` 写入格式与 `record create` / `record update` **完全一致**(key 必须是 fieldId 不是字段名;按字段类型见 [aitable-cell-value.md](./aitable-cell-value.md))。
|
||||
|
||||
## 返回结构
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"createdRecordIds": ["recX", "recY"], // 不带 recordId 的项产出
|
||||
"updatedRecordIds": ["recA", "recB"] // 带 recordId 的项产出
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`createdRecordIds` 顺序对应入参里**不带 recordId**的项(按出现顺序汇总),同理 `updatedRecordIds` 对应**带 recordId**的项。
|
||||
|
||||
## 典型用法
|
||||
|
||||
```bash
|
||||
# 1) 全部新建:所有项都不带 recordId
|
||||
dws aitable record upsert --base-id BASE --table-id TBL --records '[
|
||||
{"cells":{"fldTitleId":"任务1","fldStatusId":"待办"}},
|
||||
{"cells":{"fldTitleId":"任务2","fldStatusId":"待办"}}
|
||||
]'
|
||||
|
||||
# 2) 全部更新:所有项都带 recordId
|
||||
dws aitable record upsert --base-id BASE --table-id TBL --records '[
|
||||
{"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
|
||||
{"recordId":"rec2","cells":{"fldStatusId":"已完成"}}
|
||||
]'
|
||||
|
||||
# 3) 混合:第 1 条更新(带 recordId),第 2 条创建(不带)
|
||||
dws aitable record upsert --base-id BASE --table-id TBL --records '[
|
||||
{"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
|
||||
{"cells":{"fldTitleId":"新增任务","fldStatusId":"待办"}}
|
||||
]'
|
||||
|
||||
# 4) 长 JSON 用文件
|
||||
dws aitable record upsert --base-id BASE --table-id TBL --records-file ./batch.json
|
||||
```
|
||||
|
||||
## 与 record create / record update 的关系
|
||||
|
||||
| 场景 | 命令 |
|
||||
|------|------|
|
||||
| 确定全是新增 | `record create` |
|
||||
| 确定全是更新(每条独立 cells) | `record update` |
|
||||
| 确定全是更新(共享同一 cells) | `record batch-update` |
|
||||
| **不确定有没有,按 recordId 自动分流** | `record upsert`(本命令) |
|
||||
|
||||
`record upsert` 的 `--records` 入参格式与 `record update` 完全相同,唯一差别是 `recordId` 字段在 upsert 里是可选的。如果批次确定全是更新或全是新建,用专用命令更清晰;批次混合时(典型场景:定时同步外部数据,源里既有已存在的也有新出现的),用 upsert。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **单次最多 100 条**(创建 + 更新合计),超出请客户端拆批。
|
||||
- `cells` 的 key 必须是 fieldId 不是字段名(先用 `record query` 或 `field get` 拿 fieldId)。
|
||||
- 只读字段(formula / lookup / 系统字段)不能写入 — upsert 链路与 update 链路同样限制。
|
||||
@@ -0,0 +1,203 @@
|
||||
# 视图配置(view get/update <attr>)
|
||||
|
||||
按属性局部读/写视图配置。每个属性独立子命令,typed flag 友好,agent 不必拼 JSON。
|
||||
向后兼容:`view update --config '{...}'` 一次多属性入口仍可用。
|
||||
|
||||
## viewType × 支持矩阵
|
||||
|
||||
| viewType | card | timebar | aggregate | filter / sort / group | visible-fields | field-widths | name |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| Grid | | | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Kanban | ✅ | | | ✅ | ✅ | | ✅ |
|
||||
| Gallery | ✅ | | | ✅ | ✅ | | ✅ |
|
||||
| Gantt | | ✅ | | ✅ | ✅ | | ✅ |
|
||||
| Calendar | | | | ✅ | ✅ | | ✅ |
|
||||
| FormDesigner | (走 `form` 系列命令) | | | | | | |
|
||||
|
||||
> card 在 Kanban 走 `kanbanCard`,在 Gallery 走 `galleryCard`,CLI 自动按 viewType dispatch(preflight 1 次 `get_views`)。timebar 仅 Gantt 支持;Calendar 服务端未暴露任何 timebar 配置。
|
||||
|
||||
> **Gantt 视图必须两步创建**:`view create --view-type Gantt` 只创建空壳(`ganttTimebar: {}`),**必须**紧跟 `view update timebar --start-field <日期字段ID>` 绑定时间轴字段,否则视图打开是空白。`create_view` 的 `--config` 中传入 `ganttTimebar` 会被服务端忽略。
|
||||
|
||||
## 读取:view get <attr>
|
||||
|
||||
所有 `view get <attr>` 共用 `--base-id` / `--table-id` / `--view-id`,输出是该属性子块的 JSON(不存在时输出 `{}`)。viewType 不匹配会报错并指明应该选哪种视图。
|
||||
|
||||
```bash
|
||||
dws aitable view get card --view-id VIEW_ID --format json # Kanban / Gallery
|
||||
dws aitable view get timebar --view-id VIEW_ID --format json # Gantt
|
||||
dws aitable view get aggregate --view-id VIEW_ID --format json # Grid
|
||||
dws aitable view get filter --view-id VIEW_ID --format json # 所有
|
||||
dws aitable view get sort --view-id VIEW_ID --format json
|
||||
dws aitable view get group --view-id VIEW_ID --format json
|
||||
dws aitable view get visible-fields --view-id VIEW_ID --format json
|
||||
dws aitable view get field-widths --view-id VIEW_ID --format json # Grid
|
||||
```
|
||||
|
||||
## 写入:view update <attr>
|
||||
|
||||
所有 `view update <attr>` 共用 `--base-id` / `--table-id` / `--view-id`。
|
||||
**typed flag + `--json` 可混用**;冲突时 typed flag 优先并 stderr 提示。
|
||||
card / timebar / aggregate 三类写入有 viewType 校验(preflight 1 次 get_views)。
|
||||
|
||||
### view update card(Kanban / Gallery)
|
||||
|
||||
服务端按 viewType 分发到 `kanbanCard` 或 `galleryCard`。typed flag 共享。
|
||||
|
||||
| flag | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `--cover-field-id` | string | 封面字段 ID(Kanban / Gallery 通用),与 `--no-cover` 互斥 |
|
||||
| `--no-cover` | bool | 清除封面(等价 `coverFieldId="NONE"`) |
|
||||
| `--cover-resize-mode` | string | `cover` / `contain` / `stretch` |
|
||||
| `--hidden-field-title` | bool | 隐藏字段名标题(仅 Kanban 生效) |
|
||||
| `--cover-mode` | string | `none` / `auto` / `custom`(仅 Gallery 生效) |
|
||||
| `--display-field-name` | bool | 是否显示字段名(仅 Gallery 生效) |
|
||||
| `--json` | JSON | 完整 card 子块对象 |
|
||||
|
||||
```bash
|
||||
dws aitable view update card --view-id KANBAN_ID --cover-field-id fldAttachment --cover-resize-mode contain
|
||||
dws aitable view update card --view-id KANBAN_ID --no-cover
|
||||
dws aitable view update card --view-id GALLERY_ID --cover-mode auto
|
||||
dws aitable view update card --view-id GALLERY_ID --json '{"coverMode":"custom","coverFieldId":"fldX","displayFieldName":true}'
|
||||
```
|
||||
|
||||
### view update timebar(仅 Gantt)
|
||||
|
||||
| flag | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start-field` | string (date fieldId) | 开始日期字段 |
|
||||
| `--end-field` | string (date fieldId) | 结束日期字段 |
|
||||
| `--display-field-id` | string | 时间条上显示的标题字段 |
|
||||
| `--timeline-scale` | string | `year` / `quarter` / `month` / `weeks` |
|
||||
| `--color-configs` | JSON 数组 | 颜色配置数组(结构由下游协议定义;清空传 `[]`) |
|
||||
| `--official-holiday` | bool | 是否标注法定节假日 |
|
||||
| `--json` | JSON | 完整 ganttTimebar 子块 |
|
||||
|
||||
```bash
|
||||
dws aitable view update timebar --view-id GANTT_ID --start-field fldStart --end-field fldEnd --timeline-scale month
|
||||
dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
|
||||
```
|
||||
|
||||
### view update aggregate(仅 Grid)
|
||||
|
||||
值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
|
||||
|
||||
| flag | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
|
||||
| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
|
||||
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
|
||||
| `--json` | JSON | 完整 aggregate map |
|
||||
|
||||
```bash
|
||||
dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
|
||||
dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
|
||||
dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
|
||||
```
|
||||
|
||||
### view update field-widths(仅 Grid)
|
||||
|
||||
| flag | 类型 |
|
||||
|------|------|
|
||||
| `--field-id` + `--width` | string + int(单字段) |
|
||||
| `--json` | `{fldId: width, ...}` |
|
||||
|
||||
```bash
|
||||
dws aitable view update field-widths --view-id GRID_ID --field-id fldX --width 200
|
||||
dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB":200}'
|
||||
```
|
||||
|
||||
### view update visible-fields(通用)
|
||||
|
||||
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
|
||||
|
||||
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
|
||||
|
||||
| flag | 类型 |
|
||||
|------|------|
|
||||
| `--field-ids` | string (CSV) |
|
||||
| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
|
||||
|
||||
```bash
|
||||
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
|
||||
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
|
||||
```
|
||||
|
||||
### view update filter / sort / group(通用,纯 --json)
|
||||
|
||||
```bash
|
||||
dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'
|
||||
dws aitable view update sort --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
|
||||
dws aitable view update group --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
|
||||
```
|
||||
|
||||
> filter/sort/group 入参格式与 `record query --filters`(对象格式)**不同**:view config 这边外层必须是数组。传对象 CLI 会自动 wrap,建议直接用数组。详见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
|
||||
|
||||
### view update name(重命名)
|
||||
|
||||
```bash
|
||||
dws aitable view update name --view-id VIEW_ID --name "新视图名"
|
||||
```
|
||||
|
||||
等价于 `dws aitable view update --view-id VIEW_ID --name "新视图名"`,无 `config` 参数。
|
||||
|
||||
## 服务端字段速查(与 dws CLI 关系)
|
||||
|
||||
| dws 子命令 | 服务端 `update_view.config` 子键 | 服务端 Java 模型 |
|
||||
|---|---|---|
|
||||
| `view update card`(Kanban) | `kanbanCard` | `KanbanCardUpdateInput` |
|
||||
| `view update card`(Gallery) | `galleryCard` | `GalleryCardUpdateInput` |
|
||||
| `view update timebar` | `ganttTimebar` | `GanttTimebarUpdateInput` |
|
||||
| `view update aggregate` | `aggregate` | `Map<fieldId, AggregateAction>` |
|
||||
| `view update visible-fields` | `visibleFieldIds` | `List<String>` |
|
||||
| `view update filter / sort / group` | `filter` / `sort` / `group` | `List<Object>` |
|
||||
| `view update field-widths` | `fieldWidths` | `Map<String, Object>` |
|
||||
| `view update name` | (不在 config 内)`newViewName` 顶层 | — |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 排查"Kanban 卡片为啥不显示封面"
|
||||
|
||||
```bash
|
||||
dws aitable view get card --view-id KANBAN_ID --format json
|
||||
# → 看 coverFieldId 是不是 "NONE" 或缺失;不是再看 coverResizeMode 是不是 contain 导致裁掉
|
||||
```
|
||||
|
||||
### 创建可用的 Gantt 视图(必须两步)
|
||||
|
||||
```bash
|
||||
# 第 1 步:创建 Gantt 视图
|
||||
dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
|
||||
--view-type Gantt --name "项目甘特图" -f json
|
||||
# → 记录返回的 viewId
|
||||
|
||||
# 第 2 步(必须):绑定日期字段,否则视图为空
|
||||
dws aitable view update timebar --base-id BASE_ID --table-id TABLE_ID \
|
||||
--view-id VIEW_ID --start-field fldDateStart
|
||||
# 可选:加结束日期、标题字段、时间尺度
|
||||
# --end-field fldDateEnd --display-field-id fldName --timeline-scale month
|
||||
```
|
||||
|
||||
### 把 Gantt 时间轴改成季度尺度并加节假日
|
||||
|
||||
```bash
|
||||
dws aitable view update timebar --view-id GANTT_ID \
|
||||
--timeline-scale quarter --official-holiday=true
|
||||
```
|
||||
|
||||
### 用 dws 脚本批量替换 Kanban 封面字段
|
||||
|
||||
```bash
|
||||
for v in viw1 viw2 viw3; do
|
||||
dws aitable view update card --view-id $v --cover-field-id fldNewCover --cover-resize-mode cover --format json | jq .status
|
||||
done
|
||||
```
|
||||
|
||||
### 一次性多属性更新(仍走 legacy --config)
|
||||
|
||||
```bash
|
||||
dws aitable view update --view-id VIEW_ID --config '{
|
||||
"visibleFieldIds":["fldPrimary","fldA","fldB"],
|
||||
"filter":[{"operator":"and","operands":[]}],
|
||||
"kanbanCard":{"coverFieldId":"fldImg","coverResizeMode":"contain"}
|
||||
}'
|
||||
```
|
||||
@@ -0,0 +1,198 @@
|
||||
# 视图扩展操作(lock / frozen-cols / row-height / fill-color-rule / duplicate)
|
||||
|
||||
本文档讲 5 项视图操作命令:
|
||||
|
||||
- 锁定 / 解锁视图:`view lock` / `view get lock`
|
||||
- 冻结列:`view update frozen-cols` / `view get frozen-cols`
|
||||
- 行高:`view update row-height` / `view get row-height`
|
||||
- 数据高亮规则(条件填色):`view update fill-color-rule` / `view get fill-color-rule`
|
||||
- 复制视图:`view duplicate`
|
||||
|
||||
> **与 [aitable-view-config.md](./aitable-view-config.md) 的分工**:
|
||||
> - `aitable-view-config.md` 讲 `view get/update <attr>` 中 8 个属性:filter / sort / group / visible-fields / field-widths / aggregate / card / timebar。
|
||||
> - 本文档讲上面 5 项额外能力(包括 attr 形式的 frozen-cols / row-height / fill-color-rule,以及顶层独立的 lock / duplicate)。
|
||||
> 这 5 项**不能**通过 `view update --config '{...}'` 写入,必须用各自专属子命令。
|
||||
|
||||
## 命令矩阵
|
||||
|
||||
| 子命令 | 用途 | 必填参数 | 适用 viewType |
|
||||
|---|---|---|---|
|
||||
| `view lock [--off]` | 锁定(默认)/ 解锁视图 | `--base-id --table-id --view-id` | 全部 |
|
||||
| `view get lock` | 读取锁定状态 | `--base-id --table-id --view-id` | 全部 |
|
||||
| `view update frozen-cols --count N` | 冻结左侧 N 列(0 取消) | `--base-id --table-id --view-id --count` | Grid |
|
||||
| `view get frozen-cols` | 读取冻结列数 | `--base-id --table-id --view-id` | Grid |
|
||||
| `view update row-height --cell-height N` | 设置单元格高度(像素) | `--base-id --table-id --view-id --cell-height` | Grid |
|
||||
| `view get row-height` | 读取单元格高度 | `--base-id --table-id --view-id` | Grid |
|
||||
| `view update fill-color-rule --json '[...]'` | 全量覆盖条件填色规则 | `--base-id --table-id --view-id --json` | Grid |
|
||||
| `view get fill-color-rule` | 读取条件填色规则 | `--base-id --table-id --view-id` | 全部(其他视图返回 `[]`) |
|
||||
| `view duplicate [--new-name X]` | 复制视图 | `--base-id --table-id --view-id` | 全部 |
|
||||
|
||||
## 视图锁定 / 解锁
|
||||
|
||||
```bash
|
||||
# 锁定(默认)
|
||||
dws aitable view lock --view-id VIEW_ID
|
||||
|
||||
# 解锁
|
||||
dws aitable view lock --view-id VIEW_ID --off
|
||||
|
||||
# 查询当前是否锁定
|
||||
dws aitable view get lock --view-id VIEW_ID --format json
|
||||
# → {"data": {"baseId": ..., "tableId": ..., "viewId": ..., "locked": true|false}}
|
||||
```
|
||||
|
||||
锁定的视图禁止他人修改其配置(filter/sort/group/字段顺序等),但记录读写不受影响。锁定状态可重复 set,幂等。
|
||||
|
||||
## 冻结列(仅 Grid)
|
||||
|
||||
```bash
|
||||
# 冻结从首列起 1 列
|
||||
dws aitable view update frozen-cols --view-id VIEW_ID --count 1
|
||||
|
||||
# 取消冻结
|
||||
dws aitable view update frozen-cols --view-id VIEW_ID --count 0
|
||||
|
||||
# 查询当前冻结列数
|
||||
dws aitable view get frozen-cols --view-id VIEW_ID --format json
|
||||
# → {"data": {..., "count": 1}} count 为 null 表示视图未显式设置
|
||||
```
|
||||
|
||||
`--count` 必须 ≥ 0;负数会被拒绝。
|
||||
|
||||
## 行高(仅 Grid)
|
||||
|
||||
⚠️ **`--cell-height` 只接受 4 档枚举:32 / 56 / 88 / 128**(与前端 CELL_HEIGHTS 约定一致),其他值会被拒绝。默认值为 32。
|
||||
|
||||
```bash
|
||||
# 设置行高 — 推荐档位 32 / 56 / 88 / 128
|
||||
dws aitable view update row-height --view-id VIEW_ID --cell-height 56
|
||||
|
||||
# 查询当前行高
|
||||
dws aitable view get row-height --view-id VIEW_ID --format json
|
||||
# → {"data": {..., "cellHeight": 56}} cellHeight 为 null 表示视图未显式设置(前端按 32 渲染)
|
||||
```
|
||||
|
||||
## 数据高亮规则(条件填色,仅 Grid)
|
||||
|
||||
`view update fill-color-rule` **整组覆盖**,传 `--json '[]'` 清空所有规则。
|
||||
|
||||
### 规则结构
|
||||
|
||||
每条规则 JSON 结构:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "cell" | "row" | "column" | "preRow",
|
||||
"formatFieldId": "fldX", // 命中规则后被高亮的字段(cell/column 类型有意义)
|
||||
"format": { "color": "firstLine5" }, // ⚠️ 必须用 FORMAT_COLORS 代号,不接受 hex
|
||||
"filters": [ // 当前固定 1 条
|
||||
{
|
||||
"fieldId": "fldX", // ⚠️ 不是 operands[0]
|
||||
"symbol": "GT", // ⚠️ 不是 operator;大写枚举
|
||||
"value": 100 // 部分 symbol(EXIST/UN_EXIST)不需要 value
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### color 合法值(FORMAT_COLORS)
|
||||
|
||||
`firstLine1` ~ `firstLine11`(共 11 档色码,对应前端调色盘)。**不接受 `#FF0000` 这种 hex**。
|
||||
|
||||
### filter.symbol 合法值
|
||||
|
||||
| 类别 | symbol |
|
||||
|---|---|
|
||||
| 数值/通用比较 | `GT` / `LT` / `GTE` / `LTE` / `EQ` / `NE` |
|
||||
| 文本 | `CONTAIN` / `EXCLUSIVE` |
|
||||
| 存在性(无 value) | `EXIST` / `UN_EXIST` |
|
||||
| 多选 / 集合 | `ALL_OF` / `ANY_OF` / `NONE_OF` |
|
||||
| 日期 | `BEFORE` / `AFTER` / `NOT_BEFORE` / `NOT_AFTER` / `DATE_EQ` / `FROM_NOW` / `DATE_BETWEEN` |
|
||||
|
||||
> **与 `record query --filters` / `view update filter` 的格式不同**:那两处用 `{operator, operands}` 结构;这里是 `{fieldId, symbol, value}`。不要混用。
|
||||
|
||||
### 典型用法
|
||||
|
||||
```bash
|
||||
# 1) 给金额字段 > 100 的单元格上 firstLine5 色
|
||||
dws aitable view update fill-color-rule --view-id GRID_ID --json '[
|
||||
{
|
||||
"type":"cell",
|
||||
"formatFieldId":"fldAmount",
|
||||
"format":{"color":"firstLine5"},
|
||||
"filters":[{"fieldId":"fldAmount","symbol":"GT","value":100}]
|
||||
}
|
||||
]'
|
||||
|
||||
# 2) 清空所有规则
|
||||
dws aitable view update fill-color-rule --view-id GRID_ID --json '[]'
|
||||
|
||||
# 3) 查询当前规则
|
||||
dws aitable view get fill-color-rule --view-id GRID_ID --format json
|
||||
# → {"data": [...]} 数组
|
||||
```
|
||||
|
||||
> **写入后请用 `view get fill-color-rule` 二次确认实际生效**,以读到的 `data` 数组为准。
|
||||
|
||||
## 复制视图
|
||||
|
||||
```bash
|
||||
# 显式命名
|
||||
dws aitable view duplicate --view-id VIEW_ID --new-name "副本视图"
|
||||
|
||||
# 系统自动命名(一般是 "原视图名 (副本)")
|
||||
dws aitable view duplicate --view-id VIEW_ID --format json
|
||||
# → {"data": {..., "viewId": "<新视图ID>", "sourceViewId": "<原视图ID>", "viewName": "..."}}
|
||||
```
|
||||
|
||||
复制会保留源视图的 filter / sort / group / visible-fields / card / timebar 等全部配置;新视图的 viewId 与源视图独立。
|
||||
|
||||
## 这些字段不能用 `view update --config '{...}'` 写
|
||||
|
||||
下列字段必须用对应的专属子命令;如果错塞进 `view update --config`,CLI 会在 stderr 提示对应子命令并拒绝把字段当 view config 处理:
|
||||
|
||||
| 错误用法 | 应改用 |
|
||||
|---|---|
|
||||
| `--config '{"flags":1}'` | `view lock` / `view lock --off` |
|
||||
| `--config '{"frozenColCount":2}'` | `view update frozen-cols --count N` |
|
||||
| `--config '{"cellHeight":56}'` | `view update row-height --cell-height N` |
|
||||
| `--config '{"rowHeightLevel":"tall"}'` | `view update row-height --cell-height N`(合法档位 32/56/88/128) |
|
||||
| `--config '{"conditionalFormats":[...]}'` | `view update fill-color-rule --json '[...]'` |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 配置一个"金额超阈值红色高亮"的 Grid 视图
|
||||
|
||||
```bash
|
||||
BASE=baseXXX; TABLE=tblYYY; VIEW=viwGridZZ; FLD=fldAmount
|
||||
|
||||
# 1) 关键字段冻结,避免横向滚动看不到
|
||||
dws aitable view update frozen-cols --base-id $BASE --table-id $TABLE --view-id $VIEW --count 1
|
||||
|
||||
# 2) 加大行高让数据更易读
|
||||
dws aitable view update row-height --base-id $BASE --table-id $TABLE --view-id $VIEW --cell-height 56
|
||||
|
||||
# 3) 金额 > 100 的单元格上色
|
||||
dws aitable view update fill-color-rule --base-id $BASE --table-id $TABLE --view-id $VIEW --json "[
|
||||
{\"type\":\"cell\",\"formatFieldId\":\"$FLD\",\"format\":{\"color\":\"firstLine5\"},
|
||||
\"filters\":[{\"fieldId\":\"$FLD\",\"symbol\":\"GT\",\"value\":100}]}
|
||||
]"
|
||||
|
||||
# 4) 锁定视图,防止他人改坏
|
||||
dws aitable view lock --base-id $BASE --table-id $TABLE --view-id $VIEW
|
||||
```
|
||||
|
||||
### 复制一个"金牌客户"视图给销售团队
|
||||
|
||||
```bash
|
||||
dws aitable view duplicate --view-id viw_VIP_template --new-name "金牌客户-华东区"
|
||||
# 取返回里 data.viewId 进一步定制
|
||||
```
|
||||
|
||||
### 排查"我设置了高亮规则为啥没生效"
|
||||
|
||||
```bash
|
||||
# 看实际生效的 conditionalFormats
|
||||
dws aitable view get fill-color-rule --view-id VIEW_ID --format json
|
||||
# → 如果是 [] 说明上次写入失败;常见原因:color 用了 hex(必须 firstLineN)/ filter 用了 operator(必须 symbol)
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
# workflow — 自动化工作流管理
|
||||
|
||||
启停 / 查看 / 列出 Base 下的自动化工作流(在 AI 表格 Web 端配置的 "当 X 时自动 Y" 流程)。
|
||||
适用场景:用户问 "停掉这个流程"、"看下都有哪些自动化流程"、"流程 X 的配置是什么"。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
|
||||
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
|
||||
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
|
||||
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
|
||||
|
||||
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
> 当前**不支持通过 CLI 新建工作流**,请在 AI 表格 Web 端配置好后用 `workflow list` 拿到 ID 再启停。
|
||||
|
||||
## 命令详情
|
||||
|
||||
### workflow list — 列出工作流
|
||||
|
||||
```bash
|
||||
dws aitable workflow list --base-id BASE_ID --format json
|
||||
dws aitable workflow list --base-id BASE_ID --limit 50 --offset 100
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` | 必填 |
|
||||
| `--limit` | 可选,分页大小 `[1, 100]`,不传走服务端默认 20 |
|
||||
| `--offset` | 可选,分页偏移量 `>= 0`,不传走服务端默认 0 |
|
||||
|
||||
返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"flowId": "G-FLOW-XXXXXX", // ★ 注意字段名是 flowId
|
||||
"name": "流程1",
|
||||
"description": "当创建记录时,就更新记录",
|
||||
"status": "RUNNING", // RUNNING / STOP
|
||||
"creatorStaffId": "281493",
|
||||
"lastModifier": { "name": "李普阳", "staffId": "281493" },
|
||||
"gmtModified": 1780318540000,
|
||||
"versionId": "G-FLOW-VER-XXXXXX",
|
||||
"icons": ["..."], // 触发器+动作的图标
|
||||
"isSubFlow": false,
|
||||
"opPermissions": { "canEdit": true }
|
||||
}
|
||||
],
|
||||
"recordCount": 1, // Base 下总数
|
||||
"runningCount": 1 // RUNNING 状态的数量
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 标识字段服务端在 `list` 里叫 **`flowId`**,但在 `enable` / `disable` 出参里叫 **`workflowId`**。CLI `--workflow-id` 传任一即可(同值)。
|
||||
- `status` 是字符串枚举:`RUNNING`(启用中)/ `STOP`(已禁用),**不是** boolean。
|
||||
- `runningCount` 是当前 Base 下 status=RUNNING 的工作流数,方便快速判断「有几个流程在跑」。
|
||||
|
||||
### workflow get — 获取单个工作流详情
|
||||
|
||||
```bash
|
||||
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` | 必填 |
|
||||
| `--workflow-id` | 必填,对应 list 出参里的 `flowId` |
|
||||
|
||||
返回完整工作流配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "流程1",
|
||||
"namespace": "...",
|
||||
"status": "RUNNING",
|
||||
"versionId": "G-FLOW-VER-XXXXXX",
|
||||
"versionNo": 14,
|
||||
"versionStatus": "...",
|
||||
"accessor": {...}, // 访问者信息
|
||||
"corpId": "...",
|
||||
"flowAttribute": {...}, // 流程顶层属性
|
||||
"flowSchema": {...}, // ★ 流程节点定义(触发器/动作/分支等)
|
||||
"gmtCreate": 1780317804000,
|
||||
"gmtModified": 1780318540000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`flowSchema` 是完整的节点 DAG,结构因流程而异(条件触发器 vs 定时触发器、单分支 vs 多分支等)。agent 应按需读取关心字段,不要试图建静态 schema。
|
||||
|
||||
### workflow enable — 启用工作流
|
||||
|
||||
```bash
|
||||
dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
|
||||
```
|
||||
|
||||
返回 `{workflowId, enabled: true}` —— **`enabled: true` 是动作确认,不是当前状态查询**。要确认真启用了,必须再 `workflow list` 看 `status` 是否变成 `"RUNNING"` 或 `runningCount` 是否加 1。
|
||||
|
||||
### workflow disable — 禁用工作流(高危)
|
||||
|
||||
```bash
|
||||
dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes --format json
|
||||
```
|
||||
|
||||
返回 `{workflowId, disabled: true}` —— 同样是动作确认。禁用后该工作流不再自动触发。
|
||||
|
||||
**风险**:直接影响业务自动化(如停掉「记录创建后自动发通知」会让通知断流)。建议:
|
||||
- 操作前先 `workflow get` 留底当前配置
|
||||
- 脚本场景显式传 `--yes`;交互场景让用户在 prompt 中再次确认
|
||||
|
||||
## 能力边界
|
||||
|
||||
| 能力 | 状态 |
|
||||
|------|------|
|
||||
| 列出工作流 | ✅ |
|
||||
| 看工作流详情(含 flowSchema) | ✅ |
|
||||
| 启用/禁用 | ✅ |
|
||||
| 新建工作流 | ❌ 当前不支持,请去 AI 表格 Web 端 → 数据表 → 自动化 创建 |
|
||||
| 修改工作流配置 | ❌ 同上,需 Web UI 编辑 |
|
||||
| 删除工作流 | ❌ 同上 |
|
||||
| 查看运行历史/执行日志 | ❌ 暂未开放 |
|
||||
| 手动触发/单次运行 | ❌ 暂未开放 |
|
||||
|
||||
## 错误码速查
|
||||
|
||||
| 场景 | code | type | 备注 |
|
||||
|------|------|------|------|
|
||||
| `workflow-id` 不存在调 get | `GET_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 可能为 null,先 `workflow list` 核对 ID |
|
||||
| `workflow-id` 不存在调 enable | `ENABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 含 "场域中不存在该 namespace" |
|
||||
| `workflow-id` 不存在调 disable | `DISABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | 同上 |
|
||||
| `--limit` < 1 或 > 100 | (CLI 层拦截) | — | `--limit 必须在 [1, 100] 范围内,got N` |
|
||||
| `--offset` < 0 | (CLI 层拦截) | — | `--offset 必须 >= 0,got N` |
|
||||
|
||||
> 拿到 `*_WORKFLOW_ERROR / SYSTEM_ERROR` 时,先 `workflow list` 自查目标 ID 是否还存在、是否在当前 Base 下。
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 看看 Base 里有哪些自动化在跑
|
||||
|
||||
```bash
|
||||
dws aitable workflow list --base-id BASE_ID --format json | jq '.data | {total: .recordCount, running: .runningCount, items: .list | map({name, status, flowId})}'
|
||||
```
|
||||
|
||||
### 临时停掉某个流程做调试
|
||||
|
||||
```bash
|
||||
# 1. 留底当前状态
|
||||
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json > /tmp/wf-backup.json
|
||||
|
||||
# 2. 禁用
|
||||
dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes --format json
|
||||
|
||||
# 3. 调试做完后重启
|
||||
dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
|
||||
|
||||
# 4. 确认 status=RUNNING
|
||||
dws aitable workflow list --base-id BASE_ID --format json | jq '.data.list[] | select(.flowId == "WORKFLOW_ID") | .status'
|
||||
```
|
||||
|
||||
### 批量关掉某个 Base 下所有 workflow(调试 / 迁移前清场)
|
||||
|
||||
```bash
|
||||
for WF in $(dws aitable workflow list --base-id BASE_ID --limit 100 --format json | jq -r '.data.list[] | select(.status == "RUNNING") | .flowId'); do
|
||||
dws aitable workflow disable --base-id BASE_ID --workflow-id "$WF" --yes --format json | jq .status
|
||||
done
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `--workflow-id` 接受的就是 `list` 返回里的 `flowId`(同值,CLI 屏蔽了服务端字段名差异)。
|
||||
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
|
||||
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
|
||||
- 新建/修改/删除工作流目前必须在 AI 表格 Web 端(数据表页面 → 自动化)完成。
|
||||
@@ -35,13 +35,13 @@
|
||||
dws doc --help
|
||||
|
||||
# 查看具体命令的完整参数说明
|
||||
dws doc list --help
|
||||
dws doc read --help
|
||||
dws doc create --help
|
||||
dws doc block insert --help
|
||||
|
||||
# 查看子命令组下的所有命令
|
||||
dws doc block --help
|
||||
dws doc permission --help
|
||||
dws doc media --help
|
||||
```
|
||||
|
||||
规则:
|
||||
@@ -54,12 +54,10 @@ dws doc permission --help
|
||||
|
||||
> 命令名 → 单文件,按需加载子文档。复杂任务请优先看下方 §场景索引。
|
||||
|
||||
### 检索 / 阅读 / 元信息
|
||||
### 阅读 / 元信息
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 详见 |
|
||||
|------|------|----------|------|
|
||||
| `doc search` | 关键字搜索 / 最近访问 | — | [`doc/doc-search.md`](./doc/doc-search.md) |
|
||||
| `doc list` | 文件夹遍历 | — | [`doc/doc-list.md`](./doc/doc-list.md) |
|
||||
| `doc info` | 文档元信息(含 contentType / extension) | `--node` | [`doc/doc-info.md`](./doc/doc-info.md) |
|
||||
| `doc read` | 读取正文(markdown 或 jsonml) | `--node` | [`doc/doc-read.md`](./doc/doc-read.md) |
|
||||
|
||||
@@ -71,23 +69,31 @@ dws doc permission --help
|
||||
| `doc update` | 整篇 / 段落级更新(markdown / jsonml) | `--node` `--mode` | [`doc/doc-update.md`](./doc/doc-update.md) |
|
||||
| `doc block list/insert/update/delete` | 块级精细编辑(含 JSONML 节点操作) | `--node` (+ `--block-id`) | [`doc/doc-block.md`](./doc/doc-block.md) |
|
||||
|
||||
### 附件 / 评论 / 权限 / 导出
|
||||
### 附件 / 评论 / 导出
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 详见 |
|
||||
|------|------|----------|------|
|
||||
| `doc media insert/download` | 附件 / 图片插入与下载 | `--node` `--file` 或 `--resource-id` | [`doc/doc-media.md`](./doc/doc-media.md) |
|
||||
| `doc comment list/create/reply/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
|
||||
| `doc permission add/update/list` | 节点级授权 | `--node` `--user` `--role` | [`doc/doc-permission.md`](./doc/doc-permission.md) |
|
||||
| `doc export` / `doc export get` | 在线文档导出 docx | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
|
||||
|
||||
### 文件操作
|
||||
### 文件操作(已迁移,以下命令 deprecated)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 详见 |
|
||||
|------|------|----------|------|
|
||||
| `doc upload` | 上传文件到文档空间/知识库 | `--file` | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
|
||||
| `doc download` | 下载已有文件(非 ALIDOC) | `--node` `--output` | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
|
||||
| `doc copy` / `doc move` / `doc rename` / `doc delete` | 复制/移动/重命名/删除 | `--node` (+ ...) | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
|
||||
| `doc folder create` | 创建文件夹 | `--name` | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
|
||||
> **迁移提示**:文件管理命令已按产品领域架构重新归属,旧命令在过渡期内仍可使用,运行时会输出 deprecated 警告。
|
||||
|
||||
| 旧命令 | 推荐命令 | 详见 |
|
||||
|--------|----------|------|
|
||||
| `doc upload` | `dws drive upload` | [`drive.md`](./drive.md) |
|
||||
| `doc download` | `dws drive download` | [`drive.md`](./drive.md) |
|
||||
| `doc copy` | `dws drive copy` | [`drive.md`](./drive.md) |
|
||||
| `doc move` | `dws drive move` | [`drive.md`](./drive.md) |
|
||||
| `doc rename` | `dws drive rename` | [`drive.md`](./drive.md) |
|
||||
| `doc delete` | `dws drive delete` | [`drive.md`](./drive.md) |
|
||||
| `doc folder create` | `dws drive folder create` | [`drive.md`](./drive.md) |
|
||||
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](./wiki.md) |
|
||||
| `doc permission *` | `dws drive permission *` | [`drive.md`](./drive.md) |
|
||||
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
|
||||
| `doc search` | `dws drive search` / `dws wiki node search` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
|
||||
|
||||
### 排版规范 / JSONML 参考
|
||||
|
||||
@@ -105,7 +111,7 @@ dws doc permission --help
|
||||
|
||||
| 任务场景 | 一次性读取 | 主命令 |
|
||||
|---------|-----------|--------|
|
||||
| 定位 nodeId / URL 解析 / 目录遍历 | [`doc-search.md`](./doc/doc-search.md) + [`doc-list.md`](./doc/doc-list.md) + [`doc-info.md`](./doc/doc-info.md) | search |
|
||||
| 定位 nodeId / URL 解析 | [`doc-info.md`](./doc/doc-info.md)(搜索请用 `dws drive search` / `dws wiki node search`;遍历请用 `dws drive list` / `dws wiki node list`) | `drive search` / `wiki node search` |
|
||||
| 阅读已有文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-read.md`](./doc/doc-read.md) | read |
|
||||
| 创建新文档 | [`doc-create.md`](./doc/doc-create.md) + [`doc-update.md`](./doc/doc-update.md)(写入管道)+ [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create |
|
||||
| 创建文档且包含图片/截图/图文并茂 | [`doc-create.md`](./doc/doc-create.md) + [`doc-media.md`](./doc/doc-media.md) + [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create → media insert |
|
||||
@@ -114,16 +120,17 @@ dws doc permission --help
|
||||
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
|
||||
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
|
||||
| 评论 / 划词评论(含 @人) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws contact user search` 取 mention 用 userId) | comment create |
|
||||
| 文档分享 / 节点级权限 | [`doc-permission.md`](./doc/doc-permission.md) | permission add |
|
||||
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` |
|
||||
| 导出 PDF / DOCX | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
|
||||
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`doc-file-ops.md`](./doc/doc-file-ops.md) + [`doc-info.md`](./doc/doc-info.md)(download 前判 contentType) | upload / download / move / copy / rename |
|
||||
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"找文档/搜文档/最近文档":
|
||||
|
||||
- 搜索 → `search`
|
||||
- 浏览 → `list`
|
||||
- 全局搜索 → `dws drive search --query "<关键词>"`(聚合钉盘+文档空间)
|
||||
- 空间内搜索 → `dws wiki node search --workspace <WS_ID> --keyword "<关键词>"`
|
||||
- 遍历文件夹 → `dws drive list --workspace <WS_ID>` 或 `dws wiki node list --workspace <WS_ID>`
|
||||
|
||||
用户说"看文档/读内容/文档内容":
|
||||
|
||||
@@ -144,44 +151,42 @@ dws doc permission --help
|
||||
|
||||
用户说"建文件夹/新建目录":
|
||||
|
||||
- 创建 → `folder create`
|
||||
- 创建 → `dws drive folder create`
|
||||
|
||||
用户说"上传文件/传文件/上传到文档/上传到知识库":
|
||||
|
||||
- 上传 → `upload`(需本地文件路径)
|
||||
- 上传并转换 → `upload --convert`
|
||||
- 上传 → `dws drive upload`(需本地文件路径)
|
||||
|
||||
用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来":
|
||||
|
||||
- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`:
|
||||
- 在线文档 (alidocs/adoc) → **`export`**(格式转换后导出为 docx)
|
||||
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`download`**(直接下载原始文件)
|
||||
- 在线文档 (alidocs/adoc) → **`doc export`**(导出是内容层操作,仅对 adoc 有意义)
|
||||
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`dws drive download`**
|
||||
- 判断方法:
|
||||
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `export`
|
||||
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `download`
|
||||
3. 不确定时,先用 `info --node <ID>` 查询节点信息,根据返回的 `contentType` 字段判断:
|
||||
- `contentType` 为 `ALIDOC` → 走 `export`
|
||||
- `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等 → 走 `download`
|
||||
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `doc export`
|
||||
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `drive download`
|
||||
3. 不确定时,先用 `drive info --node <ID>` 查询节点信息,根据返回的 `contentType` 字段判断:
|
||||
- `contentType` 为 `ALIDOC` → 走 `doc export`
|
||||
- `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等 → 走 `drive download`
|
||||
|
||||
> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx,两者完全不同。
|
||||
|
||||
用户说"复制文档/拷贝文件/复制到":
|
||||
|
||||
- 复制 → `copy`(需文档 ID 或 URL + 目标位置)
|
||||
- 复制 → `dws drive copy`
|
||||
|
||||
用户说"移动文档/搬到/移到/转移文件":
|
||||
|
||||
- 移动 → `move`(需文档 ID 或 URL + 目标位置)
|
||||
- 移动 → `dws drive move`
|
||||
|
||||
用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...":
|
||||
|
||||
- 重命名 → `doc rename`(需文档 ID 或 URL + 新名称)
|
||||
- 只要意图是修改文档在列表和链接中展示的名称,统一路由到 `dws doc rename --node <DOC_ID_OR_URL> --name "新名称"`;不要走 `drive`、`doc update` 或重新 `doc create`。
|
||||
- 重命名 → `dws drive rename --node <DOC_ID_OR_URL> --name "新名称"`
|
||||
- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。
|
||||
|
||||
用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档":
|
||||
|
||||
- 删除节点 → `delete`(危险操作,需确认;需文档 ID 或 URL)
|
||||
- 删除节点 → `dws drive delete`
|
||||
|
||||
用户说"插入附件/上传附件到文档/往文档里加文件/加附件":
|
||||
|
||||
@@ -205,13 +210,14 @@ dws doc permission --help
|
||||
|
||||
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
|
||||
|
||||
- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`)
|
||||
- 修改权限 → `permission update`
|
||||
- 查看谁有权限 → `permission list`
|
||||
- 新增权限 → `dws drive permission add`
|
||||
- 修改权限 → `dws drive permission update`
|
||||
- 查看谁有权限 → `dws drive permission list`
|
||||
- 移除权限 → `dws drive permission remove`
|
||||
|
||||
> **关键区分**:
|
||||
>
|
||||
> - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持)
|
||||
> - "把**某篇文档**授权给某人" → `drive permission add`(节点级,包括「我的文档」下的文档都支持)
|
||||
> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**)
|
||||
|
||||
> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe;只有 probe 确认是 `adoc` / `file` / `folder` 后,才继续按下列意图执行。
|
||||
@@ -226,7 +232,7 @@ dws doc permission --help
|
||||
- "帮我看看这个文档" → `read`
|
||||
- "这个文档的信息" → `info`
|
||||
- "往这个文档追加内容" → `update --mode append`
|
||||
- "把这个文档标题改成 X" / "这个文档改名为 X" → `rename`
|
||||
- "把这个文档标题改成 X" / "这个文档改名为 X" → `dws drive rename`
|
||||
- "把正文里的一级标题/章节标题改成 X" → `block update`
|
||||
|
||||
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
|
||||
@@ -236,14 +242,13 @@ dws doc permission --help
|
||||
> 步骤性指引。"读哪些文件"组合参见上方 §场景索引;命令详细参数参见对应子文件。
|
||||
|
||||
```bash
|
||||
# ── 工作流 1: 浏览并阅读文档 ──
|
||||
dws doc list --format json # 1. 浏览根目录
|
||||
dws doc list --folder <DOC_FOLDER_NODE_ID> --format json # 2. 进入子目录
|
||||
dws doc info --node <DOC_ID> --format json # 3. 元信息(含 contentType)
|
||||
dws doc read --node <DOC_ID> --format json # 4. 读 markdown 正文
|
||||
# ── 工作流 1: 定位并阅读文档 ──
|
||||
dws drive search --query "<关键词>" --format json # 1. 搜索定位 nodeId(或 wiki node search)
|
||||
dws doc info --node <DOC_ID> --format json # 2. 元信息(含 contentType)
|
||||
dws doc read --node <DOC_ID> --format json # 3. 读 markdown 正文
|
||||
|
||||
# ── 工作流 2: 创建文档(含分片自动写入)──
|
||||
dws doc folder create --name "项目资料" --format json # 1. (可选) 文件夹
|
||||
dws drive folder create --name "项目资料" --format json # 1. (可选) 创建文件夹
|
||||
dws doc create --name "项目周报" --content-file /tmp/x.md \
|
||||
--folder <DOC_FOLDER_NODE_ID> --format json # 2. 创建 + 写入
|
||||
dws doc read --node <DOC_ID> --format json # 3. 回读校验(必须)
|
||||
@@ -263,14 +268,14 @@ dws doc update --node <DOC_ID> --content-file /tmp/doc.json \
|
||||
dws doc read --node <DOC_ID> --content-format jsonml # 3. 回读
|
||||
# 担心被并发覆盖时,可加 --revision <N> 触发并发检查(详见 doc-update.md)
|
||||
|
||||
# ── 工作流 5: 上传 vs 插入附件 ──
|
||||
dws doc upload --file ./report.pdf --folder <ID> # 上传作为独立文件
|
||||
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文
|
||||
# ── 工作流 5: 上传独立文件 vs 插入附件到文档正文 ──
|
||||
dws drive upload --file ./report.pdf --folder <ID> # 上传作为独立文件(存储层)
|
||||
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文(内容层)
|
||||
|
||||
# ── 工作流 6: 下载 vs 导出(先 info 判 contentType)──
|
||||
dws doc info --node <NODE_ID> --format json # 必须先查 contentType
|
||||
dws doc export --node <NODE_ID> --output ~/downloads/ # contentType=ALIDOC 走 export
|
||||
dws doc download --node <NODE_ID> --output ~/downloads/ # contentType≠ALIDOC 走 download
|
||||
dws doc export --node <NODE_ID> --output ~/downloads/ # contentType=ALIDOC 走 export(内容层)
|
||||
dws drive download --node <NODE_ID> --output ~/downloads/ # contentType≠ALIDOC 走 drive download(存储层)
|
||||
|
||||
# ── 工作流 7: 评论 + 划词(@人 需 userId)──
|
||||
dws contact user search --query "张三" --format json # 取 userId
|
||||
@@ -279,38 +284,32 @@ dws doc block list --node <DOC_ID> --format json # 划词需先
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
|
||||
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
|
||||
|
||||
# ── 工作流 8: 权限授予(节点级)──
|
||||
# ── 工作流 8: 权限授予(节点级,已迁移到 drive)──
|
||||
dws contact user search --query "张三" --format json # 取 userId
|
||||
dws doc permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
|
||||
dws doc permission list --node <DOC_ID> --format json # 校验
|
||||
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
|
||||
dws drive permission list --node <DOC_ID> --format json # 校验
|
||||
|
||||
# ── 工作流 9: 文件操作(复制/移动/重命名/删除)──
|
||||
# 第一步:获取 nodeId(三种方式按场景选一,命中即停,不要冗余调用)
|
||||
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node,跳过 search/list
|
||||
# 方式 B:按关键字找:dws doc search --query "项目周报" --format json
|
||||
# 方式 C:按文件夹遍历:dws doc list --folder <DOC_FOLDER_NODE_ID> --format json
|
||||
# 第二步:执行(--node 支持 ID 或完整 URL;--folder 支持文件夹 nodeId 或 alidocs 文件夹 URL)
|
||||
dws doc copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json # 异步任务
|
||||
dws doc move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
|
||||
dws doc rename --node <DOC_ID_OR_URL> --name "新名称" --format json
|
||||
# 删除是危险操作:必须先向用户展示「即将删除「文档名」到回收站」 → 用户确认 → 才加 --yes
|
||||
dws doc delete --node <DOC_ID_OR_URL> --yes --format json
|
||||
# ── 工作流 9: 文件操作(已迁移到 drive)──
|
||||
# 第一步:获取 nodeId
|
||||
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
|
||||
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
|
||||
# 方式 C:按文件夹遍历:dws drive list --workspace <WS_ID> --format json
|
||||
# 第二步:执行
|
||||
dws drive copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
|
||||
dws drive move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
|
||||
dws drive rename --node <DOC_ID_OR_URL> --name "新名称" --format json
|
||||
dws drive delete --node <DOC_ID_OR_URL> --yes --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | `nodes[].nodeId` | read / info / update / copy / move / rename / block 操作的 --node |
|
||||
| `list` | folder 类型的 `nodeId` | list 的 --folder, create / copy / move 的 --folder |
|
||||
| `search` | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update / copy / move / rename 的 --node;创建时间与创建者信息 |
|
||||
| `drive search` / `wiki node search` | 文档 `nodeId` / URL | doc read / info / update 等所有 `--node` 入参 |
|
||||
| `drive list` / `wiki node list` | `nodes[].nodeId` | doc read / info / update / block 操作的 --node |
|
||||
| `create` | `nodeId` | update / block 操作的 --node |
|
||||
| `folder create` | `nodeId` | create / list / upload / copy / move 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `read --content-format jsonml` | `revision` | update --content-format jsonml 的 --revision(可选,并发检查时使用) |
|
||||
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
|
||||
| `download` | 本地文件路径 | 下载后的文件保存位置 |
|
||||
| `copy` | `nodeId` / URL (异步,不保证返回) | 复制是异步任务,若任务未完成则不会返回新文档 ID;如需获取可稍后通过 list 查询目标文件夹 |
|
||||
| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 |
|
||||
| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 |
|
||||
| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id |
|
||||
@@ -319,7 +318,7 @@ dws doc delete --node <DOC_ID_OR_URL> --yes --format json
|
||||
| `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
|
||||
| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id |
|
||||
| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 |
|
||||
| `contact user search` | `userId` | comment create/reply/create-inline 的 --mention;permission add/update 的 --user |
|
||||
| `contact user search` | `userId` | comment create/reply/create-inline 的 --mention;drive permission 的 --user |
|
||||
|
||||
## 相关产品
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML normalize/validator 行为)
|
||||
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML validator 行为)
|
||||
> 3. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — JSONML 范例(含 callout / 分栏 / 表格 / 标题等节点的完整命令)
|
||||
> 4. [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) — JSONML 节点结构字段定义
|
||||
>
|
||||
> **同任务常配合**:[`doc-update.md`](./doc-update.md)(整篇 overwrite / 末尾追加纯文本)/ [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(JSONML 复制范例)
|
||||
|
||||
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端默认 normalize + validate。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
|
||||
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -88,8 +88,7 @@ Flags:
|
||||
--level int 标题级别 1-6 (配合 --heading,默认 1)
|
||||
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
|
||||
--content-format string 输入格式: 默认为 element,可选 jsonml
|
||||
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
|
||||
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
|
||||
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
|
||||
--index int 参照位置索引 (从 0 开始)
|
||||
--where string 插入方向: before / after (默认 after)
|
||||
--ref-block string 参照块 ID (优先级高于 --index)
|
||||
@@ -116,8 +115,7 @@ Flags:
|
||||
--level int 标题级别 1-6 (配合 --heading,默认 1)
|
||||
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
|
||||
--content-format string 输入格式: 默认为 element,可选 jsonml
|
||||
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
|
||||
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
|
||||
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
|
||||
```
|
||||
|
||||
> 使用 `--content-format jsonml` 时,`element` 中的 `uuid` **必须**等于 `--block-id`,否则报错。
|
||||
@@ -169,17 +167,16 @@ dws doc block update --node DOC_ID --block-id UUID --content-format jsonml \
|
||||
dws doc block delete --node DOC_ID --block-id UUID
|
||||
```
|
||||
|
||||
> 关于自动修复 / 严格校验:默认会自动注入 uuid、把裸字符串包成 span/leaf;如要禁用,用 `--no-fix-jsonml`。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
|
||||
> 关于校验:CLI 不做结构修复,裸字符串、缺 uuid 等错误会被 validator 直接抦下。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
|
||||
|
||||
## 关键说明
|
||||
|
||||
- **块类型**:paragraph、heading、blockquote、callout、columns、orderedList、unorderedList、table、sheet、attachment、slot。
|
||||
- **快捷 vs --element**:`block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型(table、callout、columns 等)使用 `--element` JSON 或 `--content-format jsonml`。
|
||||
- **简单内容追加**:建议用 [`./doc-update.md`](./doc-update.md) `--mode append`,不必走 block insert。
|
||||
- **JSONML normalize + validator**(写入端默认行为):
|
||||
- 缺 `uuid` 的 block 会自动注入;裸字符串自动包成 `["span",{"data-type":"text"},["span",{"data-type":"leaf"},"..."]]`;每条修复以 `[FIX]` 行输出。
|
||||
- 结构错误会被 validator 拦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
|
||||
- `--no-fix-jsonml` 关闭全部修复(normalize + JSON repair);`--fix-jsonml` 开启全部修复(含 JSON 语法修复),推荐 agent 调用。
|
||||
- **JSONML validator**(写入端默认行为):
|
||||
- 裸字符串、缺 uuid 等结构错误会被 validator 抦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
|
||||
- `--fix-jsonml` 开启 JSON 语法修复,推荐 agent 调用。
|
||||
- **图片插入**:插入图片走 [`./doc-media.md`](./doc-media.md) `media insert`(作为附件块),不走 block insert。
|
||||
- **分割线**:用 [`./doc-update.md`](./doc-update.md) `--content "---" --mode append`,不走 block insert。
|
||||
|
||||
@@ -265,7 +262,7 @@ dws doc block insert --node <DOC_ID> --content-format jsonml --parent-block <UUI
|
||||
|
||||
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
|
||||
- [`./doc-update.md`](./doc-update.md)(整篇改写 / 纯文本追加)
|
||||
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、normalize/validator 行为)
|
||||
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、validator 行为)
|
||||
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(所有节点的可复制 JSONML 范例)
|
||||
- [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构字段定义)
|
||||
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(callout 颜色 / 元素边界规范)
|
||||
|
||||
@@ -35,13 +35,12 @@ Flags:
|
||||
--content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取
|
||||
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
|
||||
--content-format string 内容格式: 默认为 markdown,可选 jsonml
|
||||
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
|
||||
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
|
||||
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
|
||||
```
|
||||
|
||||
## 关键说明
|
||||
|
||||
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
|
||||
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
|
||||
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
|
||||
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
|
||||
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
> **路由前置判断**:用户说「下载/导出」时**必须**先用 [`./doc-info.md`](./doc-info.md) `info --node <ID> --format json` 查 `contentType`:
|
||||
> - `contentType` 为 `ALIDOC`(在线文档)→ **必须用 `export`**,禁止用 `download`
|
||||
> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 用 [`./doc-file-ops.md`](./doc-file-ops.md) `download`
|
||||
> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 用 `dws drive download`(详见 [`../drive.md`](../drive.md))
|
||||
>
|
||||
> `download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx,两者完全不同。
|
||||
> `drive download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx,两者完全不同。
|
||||
|
||||
---
|
||||
|
||||
@@ -77,4 +77,4 @@ dws doc export get --job-id <JOB_ID> --format json
|
||||
|
||||
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
|
||||
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType=ALIDOC 才走 export)
|
||||
- [`./doc-file-ops.md`](./doc-file-ops.md)(非 ALIDOC 文件用 download)
|
||||
- [`../drive.md`](../drive.md)(非 ALIDOC 文件用 `dws drive download`)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
> 2. [`../../url-patterns.md`](../../url-patterns.md) — 仅当用户原始 `alidocs` URL 需要 probe 时
|
||||
>
|
||||
> **同任务常配合**:[`doc-search.md`](./doc-search.md) / [`doc-list.md`](./doc-list.md)(先拿到 nodeId)/ [`doc-read.md`](./doc-read.md)(确认是 ALIDOC 后读正文)
|
||||
> **同任务常配合**:`dws drive search` / `dws wiki node search`(先定位 nodeId)/ [`doc-read.md`](./doc-read.md)(确认是 ALIDOC 后读正文)
|
||||
|
||||
## 命令格式
|
||||
|
||||
@@ -59,7 +59,7 @@ Flags:
|
||||
- `dentryUuid` 是 `alidocs` URL `/i/nodes/{dentryUuid}` 的最后一段,在 `doc` 场景中等价于可传入 CLI 的 `nodeId`;不要把它改写成数字 ID。
|
||||
- `dentryId` 通常是纯数字,**不是** `doc` 的 `nodeId`,也不是 `doc --folder` 的目标文件夹 ID;不要把数字 `dentryId` 当作 `--node`、`--folder` 或 `--parent-id` 使用。
|
||||
- `parentId` / `--parent-id` 不是 `doc` 命令参数;`doc` 里目标父文件夹统一使用 `--folder <folderNodeId或folderUrl>`,目标知识库使用 `--workspace <workspaceId或workspaceUrl>`。
|
||||
- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `doc search` / `doc list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。
|
||||
- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `dws drive search` / `dws drive list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。
|
||||
|
||||
## 处理流程
|
||||
|
||||
@@ -78,8 +78,8 @@ Flags:
|
||||
| 方式 | 触发条件 | 操作 |
|
||||
|------|----------|------|
|
||||
| **A** | 用户**直接提供文档 URL 或 nodeId** | **直接传给 `--node`**,无需额外查询;优先使用此方式 |
|
||||
| **B** | 用户给出关键字 / 文档名 | `dws doc search --query "<关键字>" --format json` 从返回的 `nodes[].nodeId` 提取 |
|
||||
| **C** | 用户指向某个文件夹下的文档 | `dws doc list --folder <DOC_FOLDER_NODE_ID> --format json` 从返回中提取 |
|
||||
| **B** | 用户给出关键字 / 文档名 | `dws drive search --query "<关键字>" --format json` 或 `dws wiki node search --workspace <WS_ID> --keyword "<关键字>"` 从返回中提取 nodeId |
|
||||
| **C** | 用户指向某个文件夹下的文档 | `dws drive list --workspace <WS_ID> --format json` 或 `dws wiki node list --workspace <WS_ID>` 从返回中提取 |
|
||||
|
||||
> **关键节省**:方式 A 命中时,禁止再调 search/list "确认一下" —— 用户提供的 URL/nodeId 本身就是权威输入。同理,`--folder` 也支持 alidocs 文件夹 URL 直传,不要先 search 把 URL 解析成纯数字 ID 再传。
|
||||
|
||||
@@ -104,13 +104,13 @@ dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=749936706
|
||||
|
||||
`--folder` 参数同样支持 alidocs 文件夹 URL 或文档文件夹 nodeId。
|
||||
|
||||
不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws doc list/search` 找到文档文件夹 nodeId。
|
||||
不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws drive search` / `dws drive list` 找到文档文件夹 nodeId。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 从返回中提取 | 用于 |
|
||||
|-------------|------|
|
||||
| `contentType` + `extension` | 选择 [`./doc-read.md`](./doc-read.md) / `dws sheet ...` / `dws aitable ...` / [`./doc-file-ops.md`](./doc-file-ops.md) 的下载/导出路径 |
|
||||
| `contentType` + `extension` | 选择 [`./doc-read.md`](./doc-read.md) / `dws sheet ...` / `dws aitable ...` / `dws drive download`(非 ALIDOC 走存储层下载) |
|
||||
| `nodeId` / `docUrl` | 后续所有 `--node` 入参 |
|
||||
|
||||
## 常用模板
|
||||
@@ -145,6 +145,6 @@ dws doc info --node <WS_ID> # 错误
|
||||
## 参考
|
||||
|
||||
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
|
||||
- [`./doc-search.md`](./doc-search.md) / [`./doc-list.md`](./doc-list.md)(前置:拿 nodeId 的入口)
|
||||
- `dws drive search` / `dws wiki node search`(前置:定位 nodeId 的搜索入口,详见 [`../drive.md`](../drive.md) / [`../wiki.md`](../wiki.md))
|
||||
- [`./doc-read.md`](./doc-read.md)(contentType=ALIDOC + extension=adoc 的后续命令)
|
||||
- [`../../url-patterns.md`](../../url-patterns.md)(用户原始 alidocs URL 的 probe 流程)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
|
||||
> ⚠️ **图片插入硬规则**:
|
||||
> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws doc download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert`
|
||||
> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws drive download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert`
|
||||
> - **禁止**把钉盘/文档节点 URL(如 `alidocs.dingtalk.com/i/nodes/...`)写进 Markdown `` 图片语法——这些是页面链接,不是可渲染的图片资源
|
||||
> - 创建文档时需要图文并茂:先用 `doc create` 写入纯文本骨架,再对每张图片执行 `media insert`,最后用 `doc block list` 验证附件块存在
|
||||
|
||||
@@ -36,7 +36,7 @@ Flags:
|
||||
### 关键说明
|
||||
|
||||
- `--mime-type` 可选,不指定时根据扩展名自动推断;支持常见文件类型(PDF、Office、图片、视频、压缩包等)。
|
||||
- 与 [`./doc-file-ops.md`](./doc-file-ops.md) `doc upload` 的区别:`upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。
|
||||
- 与 `dws drive upload` 的区别:`drive upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。
|
||||
|
||||
---
|
||||
|
||||
@@ -94,5 +94,5 @@ dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>
|
||||
|
||||
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
|
||||
- [`./doc-block.md`](./doc-block.md)(block list 取 attachment 的 resourceId)
|
||||
- [`./doc-file-ops.md`](./doc-file-ops.md)(独立文件上传:`doc upload`)
|
||||
- [`../drive.md`](../drive.md)(独立文件上传:`dws drive upload`)
|
||||
- [`./style/doc-style-guideline.md` §4.9 附件与图片](./style/doc-style-guideline.md)(图示与附件使用规范)
|
||||
|
||||
@@ -27,8 +27,7 @@ Flags:
|
||||
--mode string 更新模式: overwrite=覆盖, append=追加 (必填)
|
||||
--content-format string 内容格式: 默认为 markdown,可选 jsonml
|
||||
--revision int 文档版本号(仅 --content-format jsonml 时生效,可选);传入后服务端做并发检查,版本不一致时返回 VersionConflict。不传则直接覆盖,不做并发检查
|
||||
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
|
||||
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
|
||||
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
|
||||
--index int 插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾。block 的 index 可通过 doc block list 获取。插入成功后,该位置及之后所有 block 的 index 会依次 +1
|
||||
```
|
||||
|
||||
@@ -52,7 +51,7 @@ Flags:
|
||||
]}
|
||||
```
|
||||
|
||||
> 默认模式下,CLI 会自动给缺 uuid 的 block 注入 uuid、把 `["p", {}, "hello"]` 之类的裸字符串自动包裹成上述 span/leaf 形式(每条修复都会以 `[FIX]` 行输出)。如需严格按原样发送,加 `--no-fix-jsonml`,结构错误会被 validator 直接拦下。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用全部修复。
|
||||
> CLI 不做结构修复——裸字符串、缺 uuid 等错误会被 validator 直接抦下。body 必须以 `["root", ...]` 为根节点,缺少会报错。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用 JSON 语法修复。
|
||||
|
||||
**典型流程**(无损读改写):
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
- 表格用 `table → tr → tc`(无 th/td),表头底色用 `tc` 的 `"fill"` 属性
|
||||
- 关键数据着色用 leaf 的 `"color"`(绿=好 / 红=风险)
|
||||
- 状态标记用 leaf 的 `"highlight"`(黄=待确认、绿=完成、红=阻塞)
|
||||
- uuid 可全部省略——CLI normalize 自动补充
|
||||
- uuid 必须显式提供——CLI 不再自动补充
|
||||
|
||||
## ⚠️ JSONML 结构严格约束(生成时必须遵守)
|
||||
|
||||
@@ -96,16 +96,16 @@
|
||||
## 核心规则
|
||||
|
||||
1. **每个 block 节点应有 uuid**:`["tag", {"uuid": "唯一ID"}, ...children]`
|
||||
- insert 时可以自行生成任意唯一字符串,后端会自动分配正式 uuid
|
||||
- insert 时必须提供 uuid(可自行生成任意唯一字符串,后端会自动分配正式 uuid)
|
||||
- update 时 uuid **必须**与 `--block-id` 一致
|
||||
- normalize **仅在 attrs 槽完全缺失**(如 `["p", "text"]`)时才补 uuid;如果你写了 `["p", {}, ...]`,normalize 会尊重这个空 attrs 不再补 uuid(这一行为保证 `doc read → doc update` 不污染原文档)
|
||||
- uuid 必须显式提供,不再自动补充
|
||||
2. **文本必须用 span + leaf 三层结构**,不要直接写裸字符串
|
||||
- ✅ `["p", {"uuid": "x"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "hello"]]]`
|
||||
- ❌ `["p", {"uuid": "x"}, "hello"]` — validator 会报错;默认模式下 CLI 会自动包成 ✅ 的形式并打印 `[FIX]` 行
|
||||
- ❌ `["p", {"uuid": "x"}, "hello"]` — validator 会报错,请手动包成 ✅ 的形式
|
||||
- ⚠️ `["p", {"uuid": "x"}, ["text", {}, "hello"]]` — `text` 是历史 inline tag,validator 不会报错,但建议改写为 ✅ 形式以与 `dws doc read --content-format jsonml` 的输出保持一致
|
||||
3. **attrs 对象必须存在**(即使为空):`["p", {}, ...]` 不能省略 `{}`
|
||||
|
||||
> **自动修复 vs 严格模式**:CLI 默认 normalize 会把 ❌ 的裸字符串自动包成 ✅;如要 1:1 透传原始 JSONML(例如复现服务端报错),用 `--no-fix-jsonml`,此时 validator 会以 `JSONPath + Suggestion` 形式逐条报错。如果输入来自 LLM 且可能有 JSON 语法错误(缺括号/逗号),用 `--fix-jsonml` 启用全部修复。
|
||||
> **严格模式(缺省)**:CLI 不做结构修复,裸字符串等错误会被 validator 以 `JSONPath + Suggestion` 形式逐条报错。如果输入来自 LLM 且可能有 JSON 语法错误(缺括号/逗号),用 `--fix-jsonml` 启用 JSON 语法修复。
|
||||
|
||||
## 段落 (p)
|
||||
|
||||
@@ -118,6 +118,11 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["p", {"uuid": "new2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "加粗"], ["span", {"data-type": "leaf"}, "普通"], ["span", {"data-type": "leaf", "italic": true}, "斜体"]]]'
|
||||
|
||||
# 多行文本(每行一个 p,同一 p 内的多个 span 不会换行)
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["p", {"uuid": "line1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "第一行标题"]]]' \
|
||||
--element '["p", {"uuid": "line2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二行正文内容"]]]'
|
||||
|
||||
# 带链接(link 是与 text 并列的子节点)
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["p", {"uuid": "new3"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "请访问"]], ["a", {"href": "https://example.com"}, "链接文字"]]'
|
||||
@@ -126,11 +131,73 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
**leaf 支持的格式属性**:
|
||||
- `bold: true` — 加粗
|
||||
- `italic: true` — 斜体
|
||||
- `underline: true` — 下划线
|
||||
- `underline: {"value": "single"}` — 下划线(value: `single`/`dash`/`wave`/`double`/`none`,可选 `color`)
|
||||
- `strike: true` — 删除线
|
||||
- `color: "#ff0000"` — 文字颜色
|
||||
- `dstrike: true` — 双删除线
|
||||
- `color: "#ff0000"` — 文字颜色(`#rrggbb` 格式)
|
||||
- `highlight: "#ffff00"` — 高亮背景色
|
||||
- `sz: 14` / `szUnit: "pt"` — 字号
|
||||
- `sz: 14` / `szUnit: "pt"` — 字号(szUnit 默认 `"px"`,推荐显式写 `"pt"`)
|
||||
`fonts: {"ascii": "Arial", "eastAsia": "SimHei"}` — 字体(四分区:ascii/hAnsi/cs/eastAsia,值必须使用 font-family 名称,见下方字体表)
|
||||
- `vertAlign: "superscript"` — 上标(`"subscript"` 下标,`"baseline"` 基线)
|
||||
- `spacing: 2` — 字间距(单位 pt)
|
||||
|
||||
**字体名称映射**(`fonts` 字段必须使用 font-family 值,不能写中文名):
|
||||
|
||||
| 用户说法 | font-family 值 | 用户说法 | font-family 值 |
|
||||
|---------|---------------|---------|---------------|
|
||||
| 宋体 | `SimSun` | 黑体 | `SimHei` |
|
||||
| 微软雅黑 | `Microsoft YaHei` | 微软雅黑UI | `Microsoft YaHei UI` |
|
||||
| 仿宋 | `FangSong` | 仿宋_GB2312 | `FangSong_GB2312` |
|
||||
| 楷体 | `KaiTi` | 楷体_GB2312 | `KaiTi_GB2312` |
|
||||
| 等线 | `DengXian` | 新宋体 | `NSimSun` |
|
||||
| 宋体-简 | `SimSun SC` | 宋体-繁 | `SimSun TC` |
|
||||
| 黑体-简 | `Heiti SC` | 黑体-繁 | `Heiti TC` |
|
||||
| 华文宋体 | `STSong` | 华文黑体 | `STHeiti` |
|
||||
| 华文楷体 | `STKaiti` | 华文仿宋 | `STFangsong` |
|
||||
| 华文中宋 | `STZhongsong` | 华文行楷 | `STXingkai` |
|
||||
| 华文隶书 | `STLiti` | 华文新魏 | `STXinwei` |
|
||||
| 华文细黑 | `STXihei` | 华文琥珀 | `STHupo` |
|
||||
| 苹方-简 | `PingFang SC` | 苹方-繁 | `PingFang TC` |
|
||||
| 苹方-港 | `PingFang HK` | 冬青黑-简 | `Hiragino Sans GB` |
|
||||
| 兰亭黑-简 | `Lantinghei SC` | 兰亭黑-繁 | `Lantinghei TC` |
|
||||
| 凌慧体-简 | `LingWai SC` | 幼圆 | `YouYuan` |
|
||||
| 思源黑体 | `Source Han Sans CN` | 思源宋体 | `Source Han Serif CN` |
|
||||
| 思源等宽 | `Source Han Mono SC` | 思源黑体Regular | `Source Han Sans CN Regular` |
|
||||
| 阿里普惠体2.0 | `"Alibaba PuHuiTi 2.0"` | 阿里普惠体3.0 | `"Alibaba PuHuiTi 3.0"` |
|
||||
| 钉钉进步体 | `DingTalk JinBuTi` | Adobe仿宋 | `Adobe 仿宋 Std` |
|
||||
| 方正小标宋_GBK | `FZXiaoBiaoSong-B05` | 方正小标宋简体 | `FZXiaoBiaoSong-B05S` |
|
||||
| 方正黑体 | `FZHei-B01S` | 方正楷体 | `FZKai-Z03S` |
|
||||
| 方正仿宋 | `FZFangSong-Z02S` | 方正仿宋_GBK | `FZFangSong-Z02` |
|
||||
| PMingLiU | `PMingLiU` | — | — |
|
||||
|
||||
**英文字体**(font-family 值即为字体名):
|
||||
`Arial` ・ `Calibri` ・ `Cambria` ・ `Centaur` ・ `Comfortaa` ・ `Comic Sans MS` ・ `Courier New` ・ `Franklin Gothic` ・ `Garamond` ・ `Georgia` ・ `Helvetica` ・ `Impact` ・ `Lora` ・ `Lucida Sans` ・ `Merriweather` ・ `Montserrat` ・ `Nunito` ・ `Oswald` ・ `Playfair Display` ・ `Roboto` ・ `Spectral` ・ `Times New Roman` ・ `Trebuchet MS` ・ `Verdana`
|
||||
|
||||
> **规则**:优先从上表匹配;用户指定的字体不在列表时,使用该字体在操作系统中的真实 font-family 名称(如"更纱黑体" → `Sarasa Gothic SC`)。
|
||||
|
||||
**leaf 组合示例**:
|
||||
|
||||
```json
|
||||
["span", {"data-type": "leaf", "bold": true, "color": "#C62828", "sz": 16, "szUnit": "pt"}, "红色加粗大字"]
|
||||
["span", {"data-type": "leaf", "strike": true, "color": "#9E9E9E"}, "已废弃内容"]
|
||||
["span", {"data-type": "leaf", "vertAlign": "superscript"}, "[1]"]
|
||||
["span", {"data-type": "leaf", "fonts": {"ascii": "Courier New", "eastAsia": "DengXian"}}, "等宽字体"]
|
||||
```
|
||||
|
||||
**段落级排版属性**(写在 p/h1-h6 的 attrs 上):
|
||||
- `jc: "center"` — 对齐(`left`/`center`/`right`/`both`/`justify`)
|
||||
- `spacing: {"line": 1.5, "lineRule": "auto"}` — 行距(lineRule=auto 时 line 为倍数:1=单倍、1.5=1.5倍、2=双倍)
|
||||
- `spacing: {"before": 12, "after": 8}` — 段前/段后间距(单位 pt)
|
||||
- `ind: {"firstLine": 32}` — 首行缩进(≈ 2 中文字符)
|
||||
- `ind: {"left": 96}` — 左缩进
|
||||
|
||||
**段落排版示例**:
|
||||
|
||||
```json
|
||||
["p", {"uuid": "p1", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中段落"]]]
|
||||
["p", {"uuid": "p2", "spacing": {"line": 1.5, "lineRule": "auto"}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "1.5倍行距"]]]
|
||||
["p", {"uuid": "p3", "spacing": {"line": 2, "lineRule": "auto", "before": 12, "after": 8}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "双倍行距+段前后间距"]]]
|
||||
```
|
||||
|
||||
## 标题 (h1-h6)
|
||||
|
||||
@@ -155,9 +222,11 @@ dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format json
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["p", {"uuid": "li1", "list": {"listId": "mylist1", "level": 0, "isOrdered": false}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "无序列表第一项"]]]'
|
||||
|
||||
# 有序列表项
|
||||
# 有序列表项(仅第一项设 start,后续项不设,系统自动递增)
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["p", {"uuid": "li2", "list": {"listId": "mylist2", "level": 0, "isOrdered": true, "start": 1}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表第一项"]]]'
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["p", {"uuid": "li2b", "list": {"listId": "mylist2", "level": 0, "isOrdered": true}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表第二项"]]]'
|
||||
|
||||
# 缩进子项(level: 1)
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
@@ -220,8 +289,10 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
|
||||
## 表格 (table)
|
||||
|
||||
> colsWidth 单位为 **pt**(页宽约 650pt)。如配合 `tblW: {"type": "pct"}` 则为百分比权重。
|
||||
|
||||
```bash
|
||||
# 2行2列表格
|
||||
# 2行2列表格(各列 200pt)
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["table", {"uuid": "tb1", "colsWidth": [200, 200]}, ["tr", {"uuid": "tr1"}, ["tc", {"uuid": "tc1", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题A"]]]], ["tc", {"uuid": "tc2", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题B"]]]]], ["tr", {"uuid": "tr2"}, ["tc", {"uuid": "tc3", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp3"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "数据1"]]]], ["tc", {"uuid": "tc4", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp4"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "数据2"]]]]]]]'
|
||||
```
|
||||
@@ -239,14 +310,19 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
|
||||
## 分栏布局 (columns)
|
||||
|
||||
分栏复用 table tag,通过 `sr: true` 区分。
|
||||
分栏复用 table tag,通过 `sr: true` 区分。分栏的 `tc` 可设置 `fill`(背景色)和 `border`(边框)属性提升视觉效果。
|
||||
|
||||
```bash
|
||||
# 两栏布局
|
||||
# 两栏布局(带背景色)
|
||||
dws doc block insert --node <DOC_ID> --content-format jsonml \
|
||||
--element '["table", {"uuid": "col1", "sr": true, "colsWidth": [300, 300]}, ["tr", {"uuid": "coltr"}, ["tc", {"uuid": "coltc1"}, ["p", {"uuid": "colp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "左栏内容"]]]], ["tc", {"uuid": "coltc2"}, ["p", {"uuid": "colp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "右栏内容"]]]]]]'
|
||||
--element '["table", {"uuid": "col1", "sr": true, "colsWidth": [300, 300]}, ["tr", {"uuid": "coltr"}, ["tc", {"uuid": "coltc1", "fill": "#EEF6FF", "vAlign": "top"}, ["p", {"uuid": "colp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "左栏内容"]]]], ["tc", {"uuid": "coltc2", "fill": "#FFF3E0", "vAlign": "top"}, ["p", {"uuid": "colp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "右栏内容"]]]]]]'
|
||||
```
|
||||
|
||||
**分栏视觉属性**:
|
||||
- `fill` — 单元格背景色(推荐淡色,如 `#EEF6FF` / `#FFF8E1` / `#F3E5F5`)
|
||||
- `border` — 边框配置(可选)
|
||||
- 分栏建议始终设置 `fill` 背景色,纯白底分栏视觉上与普通段落无异,读者无法感知分栏结构
|
||||
|
||||
## 嵌入块 (embed)
|
||||
|
||||
通用文件/iframe 嵌入。`embed` 是 void 块,仅含 attrs,无子节点。
|
||||
@@ -374,7 +450,7 @@ dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format json
|
||||
|
||||
| 错误写法 | 问题 | 正确写法 |
|
||||
|---------|------|---------|
|
||||
| `["p", {}, "文字"]` | 裸字符串。validator 会报 `段落子节点不能是裸字符串`;默认 normalize 会自动包成右侧形式(打印 `[FIX]` 行) | `["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "文字"]]]` |
|
||||
| `["p", {}, "文字"]` | 裸字符串。validator 会报 `段落子节点不能是裸字符串`,请手动包成右侧形式 | `["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "文字"]]]` |
|
||||
| `["p", {}, ["text", {}, "文字"]]` | `text` 是历史 inline tag,validator 不报错但服务端实际渲染的 canonical 形式是 span/leaf;为与 `doc read` 输出一致,建议改写 | 同上,用 span + data-type |
|
||||
| `["callout", {}, ...]` | 不存在 callout tag | `["container", {"subType": "colorBlocks", ...}, ...]` |
|
||||
| `["list", {}, ...]` | 不存在 list tag | `["p", {"list": {...}}, ...]` |
|
||||
|
||||
@@ -30,28 +30,23 @@ JSONML 是文档内容树的序列化格式:
|
||||
- 第二个元素为文档级属性对象(如 `sectPr` 页面设置),可选
|
||||
- 后续元素为块级节点(每个 block 节点应带 `uuid`)
|
||||
|
||||
全量覆写(overwrite)时,CLI 接受三种 body 形态:
|
||||
全量覆写(overwrite)时,CLI 要求 body 必须以 `["root", ...]` 为根节点:
|
||||
|
||||
1. `["root", {sectPr}, ...blocks]` — 服务端 canonical 形式,`doc read` 输出
|
||||
2. `[blocks, ...]` — 纯块数组(缺少 root,validator 会 warn 但不阻断)
|
||||
3. 单个 `[tag, ...]` — 当作单 block,validator warn
|
||||
2. `["root", {}, ...blocks]` — 无页面设置时用空 attrs
|
||||
|
||||
## CLI 行为概览(validator + normalize)
|
||||
## CLI 行为概览(validator)
|
||||
|
||||
写入端(`doc create/update`、`block insert/update`)默认会走 **normalize → validate** 两步:
|
||||
写入端(`doc create/update`、`block insert/update`)走 **validate** 一步,不做结构修复:
|
||||
|
||||
| 行为 | 缺省 | `--fix-jsonml` | `--no-fix-jsonml` |
|
||||
|------|------|----------------|-------------------|
|
||||
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) | ✗ |
|
||||
| 解包单 block 为 body | ✓ | ✓ | ✗ |
|
||||
| **attrs 槽完全缺失**时补 `attrs` + `uuid` | ✓ | ✓ | ✗ |
|
||||
| 裸字符串 → `span/text + span/leaf` 自动包裹 | ✓(打印 `[FIX]`) | ✓(打印 `[FIX]`) | ✗ |
|
||||
| validator 阻断(HasErrors → 拒绝发送) | ✓ | ✓ | ✓ |
|
||||
| validator 警告(warnings → 仅 stderr) | ✓ | ✓ | ✓ |
|
||||
| 行为 | 缺省 | `--fix-jsonml` |
|
||||
|------|------|----------------|
|
||||
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) |
|
||||
| validator 阻断(HasErrors → 拒绝发送) | ✓ | ✓ |
|
||||
| validator 警告(warnings → 仅 stderr) | ✓ | ✓ |
|
||||
| root 校验(仅 doc create/update) | ✓ | ✓ |
|
||||
|
||||
> **uuid 注入的边界**:仅当 attrs 槽**完全缺失**(如 `["p", "text"]`)才补 `attrs` + `uuid`。当生产者已显式给出 `attrs`(哪怕是 `{}` 或不含 uuid),normalize 一律不再补 uuid —— 视为生产者明确意图。这避免 `doc read → doc update` 回灌时污染原文档中未修改的节点(真实文档中常见 `["h1", {}, ...]` 形态)。
|
||||
|
||||
三态设计:缺省 = 结构修复 ON + JSON 语法修复 OFF;`--fix-jsonml` 全开(含 JSON repair,推荐 agent 调用);`--no-fix-jsonml` 全关(用于排查原始错误)。校验始终执行,不可跳过。
|
||||
> `doc create/update` 要求 body 必须以 `["root", {attrs?}, ...blocks]` 为根节点。缺少 root 会报错而非自动包装。`doc block insert/update` 不要求 root。
|
||||
|
||||
报错格式(面向 agent):
|
||||
|
||||
@@ -89,12 +84,12 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
| `strike` | `boolean` | `true` / `false` | 单删除线 |
|
||||
| `dstrike` | `boolean` | `true` / `false` | 双删除线(独立于 strike) |
|
||||
| `underline` | `object` | `{value, color?}` | 下划线。value: `"single"` \| `"dash"` \| `"wave"` \| `"double"` \| `"none"` |
|
||||
| `color` | `string` | CSS 颜色 | 文字颜色,如 `"#ff0000"` |
|
||||
| `color` | `string` | `"#rrggbb"` | 文字颜色 |
|
||||
| `highlight` | `string` | CSS 颜色 | 文字高亮背景色 |
|
||||
| `shd` | `object` | `{val?, color?, fill?}` | OOXML 底纹(Word 导入保留) |
|
||||
| `sz` | `number` | 数值 | 字号,配合 `szUnit` |
|
||||
| `szUnit` | `string` | `"px"` \| `"pt"` | 字号单位,默认 `"px"` |
|
||||
| `fonts` | `object` | `{ascii, hAnsi, cs, eastAsia}` | OOXML 四分区字体 |
|
||||
| `fonts` | `object` | `{ascii, hAnsi, cs, eastAsia}` | OOXML 四分区字体,值为 font-family 名称(如 `SimHei`),不能写中文名 |
|
||||
| `vertAlign` | `string` | `"superscript"` \| `"subscript"` \| `"baseline"` | 上标/下标/基线 |
|
||||
| `spacing` | `number` | 数值(pt) | 字间距 |
|
||||
|
||||
@@ -107,6 +102,17 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
["span", {"data-type": "text"},
|
||||
["span", {"data-type": "leaf", "underline": {"value": "single", "color": "#ff0000"}, "sz": 14, "szUnit": "px"}, "红色下划线"]
|
||||
]
|
||||
["span", {"data-type": "text"},
|
||||
["span", {"data-type": "leaf", "strike": true, "color": "#9E9E9E"}, "删除线灰字"]
|
||||
]
|
||||
["span", {"data-type": "text"},
|
||||
["span", {"data-type": "leaf", "fonts": {"ascii": "Arial", "eastAsia": "SimSun"}, "sz": 12, "szUnit": "pt"}, "指定字体"]
|
||||
]
|
||||
["span", {"data-type": "text"},
|
||||
["span", {"data-type": "leaf"}, "H"],
|
||||
["span", {"data-type": "leaf", "vertAlign": "subscript"}, "2"],
|
||||
["span", {"data-type": "leaf"}, "O"]
|
||||
]
|
||||
```
|
||||
|
||||
### 历史/兼容形式
|
||||
@@ -115,7 +121,7 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
|------|-----------|--------|------|
|
||||
| `["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "x"]]` | ✓ canonical | ✓ | ✅ 新内容首选 |
|
||||
| `["text", {marks}, "x"]` | ✓(`text` 在 inline 白名单中) | ✓(兼容) | ⚠️ 历史 inline tag。`doc read` 不会输出这种形式;如需复制粘贴回写、保持与现有内容一致,建议改写为 canonical |
|
||||
| `"raw string"` 作为 block 子节点 | ✗ 报错 `段落子节点不能是裸字符串` | — | 不要直接写。默认 normalize 会自动包成 canonical(打印 `[FIX]`) |
|
||||
| `"raw string"` 作为 block 子节点 | ✗ 报错 `段落子节点不能是裸字符串` | — | 不要直接写。validator 会报错,请手动包成 canonical 形式 |
|
||||
|
||||
> Marks 表的属性集对 canonical 的 leaf 和 legacy 的 text 都适用;差别仅在承载位置(leaf 的 attrs vs text 的 attrs)。
|
||||
|
||||
@@ -133,9 +139,9 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
- **tag**: `"p"`
|
||||
- **attrs**(全部 optional):
|
||||
- `jc?: "left" | "center" | "right" | "both" | "distribute" | "justify"` — 对齐
|
||||
- `ind?` — 缩进(单位 **twips**,1 twip = 1/1440 英寸)
|
||||
- `ind?` — 缩进
|
||||
- `left?: number`, `right?: number`, `firstLine?: number`, `firstLineChars?: number`, `hanging?: number`
|
||||
- `spacing?` — 行间距(单位 **twips**;`line` 字段:`lineRule=auto` 时 1/240 行,否则 twips)
|
||||
- `spacing?` — 行间距(`lineRule=auto` 时 `line` 为倍数:1=单倍、1.5=1.5倍、2=双倍;`before`/`after` 单位 pt)
|
||||
- `line?: number`, `before?: number`, `after?: number`
|
||||
- `lineRule?: "atLeast" | "auto" | "exact"`
|
||||
- `shd?: {val?, fill?, color?}` — 底纹
|
||||
@@ -148,7 +154,8 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
```json
|
||||
["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "普通段落"]]]
|
||||
["p", {"uuid": "p2", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中段落"]]]
|
||||
["p", {"uuid": "p3", "ind": {"firstLine": 420}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "首行缩进段落"]]]
|
||||
["p", {"uuid": "p3", "ind": {"firstLine": 32}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "首行缩进段落"]]]
|
||||
["p", {"uuid": "p4", "spacing": {"line": 1.5, "lineRule": "auto"}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "1.5倍行距"]]]
|
||||
```
|
||||
|
||||
### heading(标题)
|
||||
@@ -184,7 +191,7 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
- `isTaskList?: boolean` — 是否任务列表,默认 `false`
|
||||
- `isChecked?: boolean` — 任务是否完成(仅 isTaskList=true 时有意义)
|
||||
- `isCanceled?: boolean` — 任务是否取消
|
||||
- `start?: number` — 有序列表起始序号(≥1)。validator 报错:`必须 ≥1`
|
||||
- `start?: number` — 有序列表起始序号(≥1)。**仅在列表第一项设置**,后续项不设置此字段(系统自动递增)。validator 报错:`必须 ≥1`
|
||||
- `listStyleType?: string` — 样式类型(31 种预设)
|
||||
- `hideSymbol?: boolean` — 隐藏列表符号
|
||||
- **示例**:
|
||||
@@ -193,7 +200,9 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
["p", {"uuid": "li1", "list": {"listId": "abc", "level": 0, "isOrdered": false}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "无序列表项"]]]
|
||||
["p", {"uuid": "li2", "list": {"listId": "def", "level": 0, "isOrdered": true, "start": 1}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表项"]]]
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序第一项"]]]
|
||||
["p", {"uuid": "li2b", "list": {"listId": "def", "level": 0, "isOrdered": true}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序第二项(不设 start,自动编号 2)"]]]
|
||||
["p", {"uuid": "li3", "list": {"listId": "ghi", "level": 1, "isOrdered": false}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "二级缩进"]]]
|
||||
["p", {"uuid": "li4", "list": {"listId": "jkl", "level": 0, "isTaskList": true, "isChecked": false}},
|
||||
@@ -221,7 +230,10 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
|
||||
- **tag**: `"table"`
|
||||
- **attrs**:
|
||||
- `colsWidth: number[]` — 列宽(语义必传),单位 **px**。缺失时 validator 警告 `table 应提供 colsWidth`
|
||||
- `colsWidth: number[]` — 列宽(语义必传)。缺失时 validator 警告 `table 应提供 colsWidth`
|
||||
- 默认模式:值为各列绝对宽度,单位 **pt**(如 `[325, 325]` 总和≈页宽 650pt)
|
||||
- 比例模式(配合 `tblW: {"type": "pct"}`):值为百分比权重(如 `[33.3, 33.3, 33.4]` 总和=100)
|
||||
- `tblW?: {w?: number, type?: string}` — 表格宽度模式。`type: "pct"` 时 colsWidth 按比例解析
|
||||
- `sr?: boolean` — `true` 表示这是分栏布局(columns),不是普通表格
|
||||
- `jc?: string` — 对齐(源码注释"目前无消费")
|
||||
- **children**: `["tr", ...]` 行节点
|
||||
@@ -293,7 +305,7 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|
||||
- **tag**: `"table"`(复用 table tag,通过 `sr: true` 区分)
|
||||
- **attrs**:
|
||||
- `sr: true` — 固定标识
|
||||
- `colsWidth?: number[]` — 各列宽度
|
||||
- `colsWidth?: number[]` — 各列宽度(pt),前端按比例换算为页面宽度
|
||||
- `spacing?: number` — 栏间距
|
||||
- **children**: 单个 `["tr", ...]`,内含多个 `["tc", ...]`
|
||||
- **示例**:
|
||||
@@ -660,7 +672,7 @@ block tag 也可出现在 inline 上下文(如 `img` 既是 block 又是 inlin
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "列表示例"]]],
|
||||
["p", {"uuid": "li1", "list": {"listId": "l1", "level": 0, "isOrdered": true, "start": 1}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第一项"]]],
|
||||
["p", {"uuid": "li2", "list": {"listId": "l1", "level": 0, "isOrdered": true, "start": 2}},
|
||||
["p", {"uuid": "li2", "list": {"listId": "l1", "level": 0, "isOrdered": true}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二项"]]],
|
||||
["p", {"uuid": "li3", "list": {"listId": "l1", "level": 1, "isOrdered": false}},
|
||||
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "子项"]]],
|
||||
@@ -687,11 +699,11 @@ block tag 也可出现在 inline 上下文(如 `img` 既是 block 又是 inlin
|
||||
## 设计要点
|
||||
|
||||
1. **Canonical 文本是 span/leaf**:每段文字 = `["span", {"data-type":"text"}, ["span", {"data-type":"leaf", ...marks}, "..."]]`。legacy `["text", {marks}, "..."]` 仍被接受但不建议新写。
|
||||
2. **裸字符串作 block 子节点违法**:validator 报错;默认 normalize 自动包成 canonical 并打印 `[FIX]`;可用 `--no-fix-jsonml` 关闭。
|
||||
3. **每个 block 必带 `uuid`**:手写 JSONML 时建议每个 block 自带 `uuid`(base32 alphanumeric,dws CLI 用 `dws` 前缀)。写入端 normalize **仅在 `attrs` 槽完全缺失(如 `["p", "text"]`)** 时自动补一个;只要 `attrs` 槽存在(即使是 `{}`),normalize 不再补 uuid —— 这保证 `doc read → doc update` 回灌不会污染原文档节点。
|
||||
2. **裸字符串作 block 子节点违法**:validator 报错,请手动包成 canonical 形式。
|
||||
3. **每个 block 必带 `uuid`**:手写 JSONML 时建议每个 block 自带 `uuid`(base32 alphanumeric,dws CLI 用 `dws` 前缀)。
|
||||
4. **扁平列表**: 列表不嵌套,通过 `listId` + `level` 表达层级。
|
||||
5. **属性装饰**: blockquote / list / footnote 不是独立 tag,是 paragraph 的属性。
|
||||
6. **Void 节点**: hr / code / img / card / toc / embed / onlineVideo 在构造时不需要传子节点;服务端返回的真实文档中这些节点**可能包含内部配置数据子节点**,解析时应兼容。
|
||||
7. **columns = table + sr:true**: 分栏复用表格结构。
|
||||
8. **card 轻引用**: body 中只存 cardType + metadata.id,重数据在 parts 层。
|
||||
9. **root 节点**: 服务端返回的完整 body 以 `["root", {sectPr...}, ...blocks]` 包裹,写入时可直接使用此格式或纯块列表格式。
|
||||
9. **root 节点**: 服务端返回的完整 body 以 `["root", {sectPr...}, ...blocks]` 包裹。`doc create/update` 写入时必须以 root 为根节点,缺少会报错。`doc block insert/update` 不要求 root。
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
|
||||
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
|
||||
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
|
||||
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
|
||||
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
|
||||
@@ -75,13 +75,171 @@
|
||||
|
||||
---
|
||||
|
||||
## 设计规划(JSONML 起稿前必做)
|
||||
|
||||
判定走 JSONML 路径后,**禁止立即动手写 JSONML**。先完成以下两阶段规划,各自产出一个持久文件作为后续阶段的锚点。
|
||||
|
||||
### 设计原则(全程生效)
|
||||
|
||||
1. **结构即信息** — 标题层级、表格 vs 分栏 vs 列表、callout 位置都应编码内容逻辑。问自己:“去掉这个结构元素,读者会丢失信息或体验变差吗?”— 丢失信息则必保留;不丢失信息但能提升可读性或美观度(如分割线分隔章节、分栏对比排版)也应保留;既不携带信息也不提升体验的装饰元素才删除。
|
||||
2. **视觉层级引导阅读** — 每一屏必须让读者瞥一眼就能回答:“这块最重要的是什么?”字号/粗体/颜色形成明确梯度:标题 > 重点数据 > 正文 > 辅助信息。
|
||||
3. **克制产生质感** — 遵循 60-30-10 配色比例:60% 中性底色(白/浅灰)、30% 辅助色、10% 强调色。多色系共存时需保持**同等饱和度**并各有语义角色(如淡蓝=信息、淡黄=提示、淡红=风险),同一色系内深浅变化自由。callout 不超过 2 个。
|
||||
4. **设计先于执行** — 从规划阶段起每个结构块的样式就已确定,执行时(无论直接 JSONML 还是脚手架精修)只是落地已有设计,不是边写边想。
|
||||
5. **同类同色、一色多阶** — 同类信息必须使用相同色系;单一色系按元素角色展开为深/中/浅/极浅四级(标题文字用深色、强调用中色、高亮/表头用浅色、背景用极浅色),不要全篇只用一个 hex 值。
|
||||
|
||||
### Phase 1:RFC — 需求理解与设计方向
|
||||
|
||||
**目标**:明确“做什么”和“为什么这样做”,形成方向性锚点。
|
||||
|
||||
#### 1.1 需求提取(全量列出用户显式要求)
|
||||
|
||||
通读用户 prompt,抽取两类要求并编为清单:
|
||||
|
||||
**内容要求**:
|
||||
- 标题、字数、章节划分
|
||||
- 数据来源、受众
|
||||
- 语气/风格(如“大气”“专业”“轻松”)
|
||||
|
||||
**样式要求**(每一条都必须在最终输出中体现,不得遗漏):
|
||||
- 字体:映射为 font-family 名称(参照 cookbook 字体映射表),区分“全文字体”和“特定元素字体”
|
||||
- 字号、行距、对齐、颜色
|
||||
- 强调手段(加粗、高亮、配色…)
|
||||
- 约束(如“每部分不省略”)
|
||||
|
||||
> 用户没有明确指定的维度(如未指定行距、未指定表格样式)由 Phase 2 补充设计决策。
|
||||
|
||||
#### 1.2 内容-表现适配(每个章节的内容适合用什么元素)
|
||||
|
||||
对每个章节回答:“这个内容的核心是什么类型的信息?”→ 选择最佳元素:
|
||||
|
||||
**块级结构元素**:
|
||||
|
||||
| 信息类型 | 首选元素 | 不适合 |
|
||||
|----------|---------|--------|
|
||||
| 多个同类实体对比(≥ 4 项或 ≥ 3 维度) | 彩色表头表格 | 纯文本段落 |
|
||||
| 少量实体对比(2-3 项× 少量维度) | 分栏(每栏一个实体,可设边框/背景色) | 大宽表格 |
|
||||
| 时间序列/流程(行程/步骤) | 有序列表 + 粗体时间标签 | 无序列表 |
|
||||
| 单个结论/推荐/重要提示 | callout(“花大胆”的地方) | 普通段落 |
|
||||
| 描述性文字(背景/说明) | 正文段落 + 关键词粗体 | 表格 |
|
||||
| 分类列举(特色/亮点) | 无序列表 | 表格(数据不够多列时) |
|
||||
| 数值强调(评分/价格/统计) | 加粗 + 着色 | 跳过不强调 |
|
||||
| 引用原文(用户评价/网友点评/官方说明) | 引用块 | 普通段落 |
|
||||
| 任务/待办清单 | checklist(`- [ ]`) | 普通列表 |
|
||||
| 章节分隔/主题转换 | 分割线(`hr`) | 空行 |
|
||||
| 板块内子区域分隔(同一单元格/容器内多个逻辑段) | hr 内部分隔(在 tc 或 container 内部使用) | 空行或留白 |
|
||||
| 结构化元信息(人/时间/地点/属性清单) | 键值对表格(窄标签列 ~15-20% + 宽内容列) | 多行段落 |
|
||||
| 分类标签/状态标记 | 标签元素(tag) | 纯文本标记 |
|
||||
|
||||
**行内强调元素**:
|
||||
- **emoji** — 用于 callout 前缀、状态标记、H2/H3 标题前;不在普通段落和列表项中滥用
|
||||
- **加粗/高亮/着色** — 强调关键数据和结论
|
||||
- **highlight 色带** — 在标题 span 上设 `"highlight": "#浅色"` 形成轻量色条标记,比 callout 更轻,适合区分多个并列板块的主题色
|
||||
- **灰色辅助文字** — 用浅灰色(如 `#979A9B`)标记示例/说明/占位文字,与正文形成明确的主次层级
|
||||
- **图片** — 实景照片、截图、示意图能显著提升理解时使用
|
||||
|
||||
**分栏选型补充**:分栏栏数无上限,但推荐 2-4 栏(≥5 栏会比较拥挤),可设置边框和背景色。**建议设置 `fill` 背景色**(纯白底分栏视觉上与普通段落无异,读者不易感知分栏结构)。适合场景:
|
||||
- 2-3 个同类实体并排展示(每栏一个实体,含标题 + 描述 + 关键数据)
|
||||
- 轻量对比:“优点 vs 缺点”、“方案 A vs B”、“Day 1 vs Day 2”
|
||||
- 网格布局:多次插入同栏数分栏可形成卡片网格(如 2×3、3×2),适合 4-9 个结构相同、内容等长的卡片式实体。**约束**:每个格子内容必须结构一致且长度相近,否则高低不齐会很丑;实体数不能整除栏数时不使用
|
||||
|
||||
例如:“5 个实体多维度对比” → **彩色表头表格**;“两组信息并排” → **分栏**;“6 个结构相同的卡片” → **2×3 分栏网格**。
|
||||
|
||||
#### 1.3 起稿策略选择
|
||||
|
||||
根据文档复杂度和上方适配结果,确定起稿路径:直接 JSONML / Markdown 脚手架 + JSONML 精修 / 直接 JSONML 分段构造。具体条件和流程见下方「起稿」节的策略表。
|
||||
|
||||
#### 落盘
|
||||
|
||||
将以上内容写入 `<name>-rfc.md`,包括:
|
||||
- 需求摘要(内容要求 + 样式要求)
|
||||
- 每章展现策略 + 选择理由
|
||||
- 起稿策略
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:Spec — 精确设计参数
|
||||
|
||||
**目标**:将 RFC 的方向决策转化为可直接执行的参数。
|
||||
|
||||
#### 2.1 视觉体系设计(确定全局设计变量)
|
||||
|
||||
在以下四个维度做出明确选择,每个选择都必须能解释为什么适合这篇文档:
|
||||
|
||||
**色彩**(选定主色后展开为色阶):
|
||||
|
||||
用户指定颜色时(如"蓝色""绿色"),不要全篇只用一个 hex 值。将其展开为 4 级色阶,按元素角色分配:
|
||||
|
||||
| 角色 | 用途 | 色阶要求 |
|
||||
|------|------|----------|
|
||||
| 深色 | 标题文字、重点数据 `color` | 白底上高对比可读 |
|
||||
| 中色 | 正文强调、链接 `color` | 辨识度高但不抢标题 |
|
||||
| 浅色 | highlight 色带、表头 fill | 底色柔和,上方深色文字可读 |
|
||||
| 极浅 | container bgcolor、大面积背景 | 接近白色,仅提供区域感 |
|
||||
|
||||
**规则**:
|
||||
- 深→浅的层级关系不可颠倒(不能用极浅色做标题文字、不能用深色做背景)
|
||||
- 同类板块/同类标题必须使用完全相同的色阶组合,通过色彩的重复形成视觉韵律
|
||||
- 不同类别可用不同色系区分(如:任务=蓝系、风险=红系、成果=绿系)
|
||||
- 遵循 60-30-10 配色比例:60% 中性底色、30% 辅助色、10% 强调色;用户指定的颜色值优先
|
||||
|
||||
**字体梯度**(形成明确层级):
|
||||
- 主标题(h2):字体 / 字号 / 粗体 / 颜色
|
||||
- 正文:字体 / 字号 / 行距
|
||||
- 强调文字:粗体 + 颜色或高亮
|
||||
- 辅助信息(注释/来源):字号偏小 / 灰色
|
||||
|
||||
**表格风格**(有表格时):
|
||||
- 表头:底色 + 文字色 + 是否粗体
|
||||
- 单元格:默认对齐 / 字号
|
||||
|
||||
**视觉重心**(克制原则落地):
|
||||
全篇选 1-2 处给予最强视觉处理(配色 callout / 彩色表头 / 分栏对比),其余元素保持朴素。不要处处强调 — 处处强调等于没有强调。
|
||||
|
||||
callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸图标(如“灯泡”“火”“钉子”),增强语义标识。设置了 sticker 后,高亮块内首个段落不要再以 emoji 开头,避免紧邻的位置出现两个图标。
|
||||
|
||||
#### 落盘与回验
|
||||
|
||||
1. 将以上内容写入 `<name>-design.md`,包括:
|
||||
- 逐章元素映射(用什么标签、什么属性)
|
||||
- 色阶具体 hex 值
|
||||
- 字体梯度参数
|
||||
- 表格风格细节
|
||||
|
||||
2. **回验**:Read `<name>-rfc.md`,逐条确认:
|
||||
- [ ] RFC 中每条需求在 Spec 中有对应实现
|
||||
- [ ] 展现策略在 Spec 中有具体参数支撑
|
||||
- [ ] 配色遵循 60-30-10 比例、callout ≤ 2 个、多色系饱和度一致且有语义角色
|
||||
|
||||
回验通过后进入下一节开始构造 JSONML。
|
||||
|
||||
---
|
||||
|
||||
## JSONML 起稿(命中判定时使用)
|
||||
|
||||
当判定为 JSONML 起稿时,在本地临时 JSON 文件中直接构造完整的 JSONML 文档树。路径:`/tmp/<name>.json`。
|
||||
根据 RFC 中确定的起稿策略执行:
|
||||
|
||||
| 策略 | 条件 | 执行流程 |
|
||||
|------|------|----------|
|
||||
| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `/tmp/<name>.json` 手写完整 JSONML 树 → `doc create --content-format jsonml` |
|
||||
| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `doc create --content-format markdown` ② `doc read --node <id> --content-format jsonml --output /tmp/<name>.json` 拉回 JSONML(已含 uuid) ③ **Read `<name>-rfc.md` + `<name>-design.md`** 回顾规划 ④ 逐章对照执行结构变换 + 叠加样式 |
|
||||
| **直接 JSONML 分段构造** | 长文档且含大量富结构 | 按章节分段构造 JSONML,每段写完校验通过后再写下一段,最后拼接 |
|
||||
|
||||
> **脚手架策略警示**:Markdown 无法表达分栏/callout/色彩表头,拉回的 JSONML 只有纯文本骨架。精修阶段不是“在现有结构上加色”,而是“参照 RFC/Spec 重组结构”。
|
||||
|
||||
> **MUST READ**:动手写 JSONML 前,必须先用 Read 工具读取 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md) — 其中 §决策型文档骨架范例 有可直接复制修改的完整模板。
|
||||
> 节点类型和属性的权威定义见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md)。
|
||||
|
||||
### ⚠️ JSONML 降级约束
|
||||
|
||||
**禁止因一次校验失败就放弃 JSONML 降级为 Markdown。** 当用户需求已触发 JSONML 起稿判定时,JSONML 是实现其样式要求的首选路径。失败时的处理策略:
|
||||
|
||||
1. **校验报错** → 读取错误信息,定位具体节点,修复后重试
|
||||
2. **JSON 语法错误** → 检查括号匹配、逗号、引号,修复后重试
|
||||
3. **反复失败(≥3 次)** → 尝试简化结构(减少嵌套、拆分复杂节点)再试
|
||||
4. **仍然失败** → 退化为「Markdown 脚手架 + JSONML 精修」路径(流程同上方策略表),并告知用户当前状况
|
||||
|
||||
> “由于 JSONML 结构复杂且容易出错,改用 Markdown” — 这不是合法降级理由。必须先充分重试,且降级后仍需通过精修补回样式。
|
||||
|
||||
### ⚠️ JSONML 结构严格约束(生成时必须遵守)
|
||||
|
||||
每个节点是一个 JSON 数组:`[tagName, attributes?, ...children]`
|
||||
@@ -99,6 +257,10 @@
|
||||
| 多余逗号 | `["p", {},]` | JSON 解析失败 |
|
||||
| 缺少逗号 | `["p", {} ["span"]]` | JSON 解析失败 |
|
||||
| 引号不匹配 | `["p", {"uuid": "abc}]` | JSON 解析失败 |
|
||||
| 有序列表每项都设 `start:1` | `{"start":1}` 在每项重复 | 所有项编号重置为 1(显示为 a/a/a) |
|
||||
| 列表 `level` 从 1 开始 | `"level": 1` 作为顶级 | 顶级列表项多一层缩进;建议:顶级 `"level": 0`,子级 `"level": 1` |
|
||||
| 用 `fontFamily` 设字体 | `"fontFamily": "Arial"` | 校验报错;正确写法:`"fonts": {"ascii": "Arial", "eastAsia": "..."}` |
|
||||
| 用多个 span “换行” | 同一 `p` 内放两个 `span` | 不会产生换行;每个换行必须是独立的 `p` 节点 |
|
||||
|
||||
### 基础结构
|
||||
|
||||
@@ -114,14 +276,20 @@
|
||||
- 根节点固定 `"root"`(不是 `"body"`)
|
||||
- `--name` 已是 H1,JSONML 从 `h2` 开始
|
||||
- 表格结构是 `table → tr → tc`(无 `th`/`td`)
|
||||
- uuid 可省略(CLI normalize 自动补)
|
||||
- 分栏是 `table` + `"sr": true`,`tc` 建议设 `fill` 背景色
|
||||
- 有序列表:仅第一项设 `"start": 1`,后续项不设 `start`(系统自动递增)
|
||||
- 列表 `level` 建议从 0 开始:顶级项 `"level": 0`,子项 `"level": 1`,以此类推
|
||||
- uuid 必须显式提供(CLI 不自动生成)
|
||||
- **每行内容对应一个 `p` 节点** — 同一 `p` 内的多个 `span` 不会换行,只会横向拼接;需要换行时必须拆分为多个 `p`
|
||||
|
||||
### 视觉设计要点
|
||||
|
||||
构造时主动使用这些属性实现视觉效果:
|
||||
- **文字着色**:leaf 上 `"color": "#hex"`、`"highlight": "#hex"`
|
||||
- **highlight 色带**:标题 leaf 上 `"highlight": "#浅色"` 可形成色条效果(比 callout 更轻量的板块标记)
|
||||
- **字号**:leaf 上 `"sz": 14, "szUnit": "pt"`
|
||||
- **callout**:`["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#E8F5E9", "border": "left"}}, ...blocks]`
|
||||
- **callout + sticker**:`["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#FEF3F3", "showstk": true, "sticker": "火"}}, ...blocks]`
|
||||
- **表格单元格底色**:tc 上 `"fill": "#hex"`
|
||||
|
||||
### 写入
|
||||
@@ -147,12 +315,12 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
|
||||
- 只使用用户已提供或对话中已确认的正文素材。
|
||||
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
|
||||
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
|
||||
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
|
||||
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
|
||||
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
|
||||
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
|
||||
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
|
||||
- Markdown 草稿阶段**不要**写 callout / 分栏 / 附件——这些留到「创建后的精修」用 `doc block insert` 操作(style-guideline §1.3)。
|
||||
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node <nodeId> --file <本地图片路径>` 插入,最后用 `dws doc block list --node <nodeId>` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws doc download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。
|
||||
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node <nodeId> --file <本地图片路径>` 插入,最后用 `dws doc block list --node <nodeId>` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws drive download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。
|
||||
|
||||
## 创建写入
|
||||
|
||||
@@ -230,7 +398,7 @@ dws doc update --node <nodeId> --content-file /tmp/<name>-resume.md --mode appen
|
||||
|
||||
精修常见入口(**按 [doc-update-workflow.md §1.3](./doc-update-workflow.md) 优先级排序:JSONML 首选**):
|
||||
|
||||
- 单 block JSONML 精修(首选):`doc block list --node <id> --content-format jsonml --block-id <uuid>` 取子树 → `doc block update --node <id> --block-id <uuid> --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认 normalize + validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md))
|
||||
- 单 block JSONML 精修(首选):`doc block list --node <id> --content-format jsonml --block-id <uuid>` 取子树 → `doc block update --node <id> --block-id <uuid> --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认执行 schema validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md))
|
||||
- 整篇 JSONML 无损:`doc update --content-format jsonml --mode overwrite`(默认直接覆盖,适合一次改多处或改 root sectPr;担心并发覆盖时加 `--revision <N>` 触发并发检查)
|
||||
- 插入附件 / 图片:`doc media insert`(无 JSONML 形态,直接走 element)
|
||||
- element JSON 次选:`doc block insert` / `doc block update` 不带 `--content-format jsonml` 时按老接口 JSON 解析;仅在 JSONML 不支持某字段时使用
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
1. **读者**:谁打开这篇文档?读完要做什么动作(操作 / 选择 / 查参数 / 看推理)?
|
||||
2. **唯一记忆点**:读者关掉文档后,最想让他记住的一句话是什么?这句话决定开头摘要 / callout 该写什么。
|
||||
3. **形态**:按 §2.0 推荐格式列 + [doc-create-workflow.md §JSONML 起稿判定](./doc-create-workflow.md#jsonml-起稿判定) 决定路径。命中判定条件(决策型 / 含对比的知识沉淀型 / 用户意图关键词)→ **直接 JSONML 起稿**;未命中 → markdown 起稿 + 创建后精修。
|
||||
3. **形态**:按 §2.0 推荐格式列 + [doc-create-workflow.md §JSONML 起稿判定](./doc-create-workflow.md#jsonml-起稿判定) 决定路径。命中判定条件(决策型 / 含对比的知识沉淀型 / 用户意图关键词)→ **直接 JSONML 起稿**(但必须先完成 [doc-create-workflow.md §设计规划](./doc-create-workflow.md#设计规划jsonml-起稿前必做) 的 4 步规划);未命中 → markdown 起稿 + 创建后精修。
|
||||
|
||||
写完自检时回看这三个答案:开头有没有兑现「记忆点」、形态有没有兑现「推荐格式」。两条任一不兑现,按 §8 自检表对应行动。
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
|
||||
|
||||
| 优先级 | 形态 | 适用 |
|
||||
|--------|------|------|
|
||||
| ① 首选 | `--content-format jsonml` | 保真度最高;callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套结构都能 1:1 round-trip;写入端有 normalize + validator 兜底(§4.4) |
|
||||
| ① 首选 | `--content-format jsonml` | 保真度最高;callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套结构都能 1:1 round-trip;写入端有 validator 兜底(§4.4) |
|
||||
| ② 次选 | `--content-format element`(JSON,老接口) | JSONML 不支持某个块字段时;或快速插入 callout / 分栏不想构造 JSONML 时;不保真改写正文 |
|
||||
| ③ 兜底 | markdown(不带 `--content-format` 即默认)| 纯文本追加、整篇重排骨架;callout / 分栏 / 颜色 / 部分属性会被 markdown 还原过程丢失 |
|
||||
|
||||
@@ -106,7 +106,7 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
|
||||
|
||||
## 四、改写路径详细
|
||||
|
||||
> **首选 JSONML(§4.4)**——保真度最高且 normalize/validator 兜底;本节其余路径(markdown / element)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
|
||||
> **首选 JSONML(§4.4)**——保真度最高且 validator 兜底;本节其余路径(markdown / element)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
|
||||
|
||||
### 4.1 段落级 overwrite(markdown 兜底路径)
|
||||
|
||||
@@ -161,7 +161,7 @@ dws doc block insert --node <nodeId> --ref-block <BLOCK_ID> --where after --cont
|
||||
|
||||
### 4.4 JSONML 无损改写(**首选路径**)
|
||||
|
||||
> 改写已有文档**默认走本节**——保真度最高,callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 normalize + validator 兜底。其他路径(§4.1/4.2/4.3/4.5 markdown)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
|
||||
> 改写已有文档**默认走本节**——保真度最高,callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。其他路径(§4.1/4.2/4.3/4.5 markdown)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
|
||||
|
||||
两条子路径:
|
||||
|
||||
@@ -201,16 +201,15 @@ dws doc update --node <nodeId> --content-file /tmp/doc_modified.json \
|
||||
|
||||
> **并发安全模式(担心被并发覆盖时使用)**:如果担心多 agent 同时改这篇文档,可以把第 1 步 read 返回的 `revision` 通过 `--revision <N>` 透传给第 4 步:服务端会做并发检查,版本不一致返回 `VersionConflict`,此时回到第 1 步重读重写即可。普通单 agent 改写场景默认不传 `--revision`。
|
||||
|
||||
#### JSONML 写入端的 normalize 与 validator
|
||||
#### JSONML 写入端的 validator
|
||||
|
||||
写入命令(`doc create/update` + `doc block insert/update`)默认按 **normalize → validate** 两步处理 JSONML:
|
||||
写入命令(`doc create/update` + `doc block insert/update`)走 **validate** 一步,不做结构修复:
|
||||
|
||||
| 行为 | 缺省 | `--fix-jsonml` | `--no-fix-jsonml` |
|
||||
|------|------|----------------|-------------------|
|
||||
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) | ✗ |
|
||||
| 注入缺失的 block `uuid` | ✓ | ✓ | ✗ |
|
||||
| 裸字符串 → 包成 `["span",{"data-type":"text"},["span",{"data-type":"leaf"},"..."]]` | ✓(stderr 打印 `[FIX]`) | ✓ | ✗ |
|
||||
| validator 阻断(HasErrors → 拒发) | ✓ | ✓ | ✓ |
|
||||
| 行为 | 缺省 | `--fix-jsonml` |
|
||||
|------|------|----------------|
|
||||
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) |
|
||||
| validator 阻断(HasErrors → 拒发) | ✓ | ✓ |
|
||||
| root 校验(仅 doc create/update) | ✓ | ✓ |
|
||||
|
||||
报错格式(agent 友好):
|
||||
|
||||
@@ -219,11 +218,11 @@ $[2][2]: paragraph child must be span wrapper, got raw string.
|
||||
Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your text>"]]
|
||||
```
|
||||
|
||||
三态设计:
|
||||
设计要点:
|
||||
|
||||
- 不传:结构修复 ON + JSON 语法修复 OFF + 校验 ON(推荐人工调用)
|
||||
- `--fix-jsonml`:全部修复 ON(含 JSON 语法修复,推荐 agent 调用)
|
||||
- `--no-fix-jsonml`:全部修复 OFF,校验仍 ON;用于排查原始错误
|
||||
- 缺省为严格模式:不做结构修复,裸字符串、缺 uuid 等错误会被 validator 抦下。
|
||||
- `doc create/update` 要求 body 必须以 `["root", ...]` 为根节点,缺少会报错。`doc block insert/update` 不要求 root。
|
||||
- `--fix-jsonml`:启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),推荐 agent 调用。
|
||||
|
||||
**何时不走本节、改用 markdown**:纯文本追加章节(§4.2)、整篇按全新骨架重写(§4.5,且无富结构需要保留时)、只在乎"加一段文字"且确认目标段落无 callout / 分栏 / 颜色 / @人 / 附件。其余场景默认本节。
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ dws drive --help
|
||||
|
||||
# 查看具体命令的完整参数说明
|
||||
dws drive list --help
|
||||
dws drive search --help
|
||||
dws drive upload --help
|
||||
dws drive download --help
|
||||
```
|
||||
@@ -30,7 +31,7 @@ Example:
|
||||
dws drive list --limit 20
|
||||
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
|
||||
Flags:
|
||||
--limit int 每页返回数量,默认 20,最大 100 (可选)
|
||||
--limit int 每页返回数量,默认 20,最大 50 (可选)
|
||||
--cursor string 分页游标,首次不传 (可选)
|
||||
--order string 排序方向: asc|desc,默认 desc (可选)
|
||||
--order-by string 排序字段: createTime|modifyTime|name (可选)
|
||||
@@ -65,6 +66,43 @@ spaceType 筛选规则:
|
||||
- `spaceType` — 空间类型(如 `orgSpace`)
|
||||
- `nextToken` — 若不为空,表示还有更多空间可查询(仅企业空间)
|
||||
|
||||
### 搜索钉盘文件/文件夹/空间
|
||||
|
||||
按关键词在钉盘中搜索文件、文件夹或团队空间。不同于 `list`(需要明确的 spaceId/parentId 逐层遍历),`search` 用于不知道具体位置、只记得名称/关键词的场景。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws drive search [flags]
|
||||
Example:
|
||||
dws drive search --query "季度汇报"
|
||||
dws drive search --query "合同" --target file --extensions pdf,docx
|
||||
dws drive search --query "项目" --target space
|
||||
dws drive search --query "方案" --created-from 1700000000000 --created-to 1710000000000
|
||||
dws drive search --query "周报" --creator-uids 012345
|
||||
dws drive search --query "报告" --limit 30 --cursor <pageToken>
|
||||
Flags:
|
||||
--query string 搜索关键词 (必填)
|
||||
--target string 搜索目标: all(默认) | file | space (可选)
|
||||
--file-types strings 按文件内容类型过滤,逗号分隔: alidoc,document,image,video,audio,archive (仅 target=file/all 生效)
|
||||
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,adoc)
|
||||
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
|
||||
--created-from int 创建时间起始 (毫秒时间戳,含)
|
||||
--created-to int 创建时间截止 (毫秒时间戳,含)
|
||||
--modified-from int 修改时间起始 (毫秒时间戳,含)
|
||||
--modified-to int 修改时间截止 (毫秒时间戳,含)
|
||||
--limit int 每页返回数量(默认 10,最大 30)
|
||||
--cursor string 分页游标,从上次返回的 nextCursor 获取 (可选)
|
||||
```
|
||||
|
||||
搜索目标 (`--target`) 选择规则:
|
||||
- `all`(默认):同时搜文件与空间,返回混合结果 — 不确定目标是文件还是空间时使用
|
||||
- `file`:只搜文件 / 文件夹,支持 `--file-types` / `--extensions` 过滤 — 明确是找文件时使用
|
||||
- `space`:只搜团队空间 — 明确知道空间名、需快速定位空间 spaceId/rootFolderId 时使用
|
||||
|
||||
返回结果中 `type` 字段区分:`SPACE`(空间)、`FILE`(普通文件)、`FOLDER`(文件夹)、`ALIDOC`(钉钉在线文档)。
|
||||
|
||||
> **提示**:结果按相关性排序,首页未命中时优先调整关键词 / 补充 `--file-types`/`--extensions` 缩小范围 / 加上时间范围,而非反复翻页。
|
||||
|
||||
### 获取文件元数据信息
|
||||
|
||||
```
|
||||
@@ -85,8 +123,8 @@ Flags:
|
||||
| extension | 文件类型 | 操作 | 命令 |
|
||||
|-----------|---------|------|------|
|
||||
| adoc | 在线文档 | 在线获取 Markdown 内容 | `dws doc read --node <fileId>` |
|
||||
| axls | 在线表格 | 在线读取表格数据 | `dws sheet list` → `dws sheet range read` |
|
||||
| able | 多维表格 | 在线查询记录 | `dws aitable table list` → `dws aitable record query` |
|
||||
| axls | 在线表格 | 在线读取表格数据 | `dws sheet get-all-sheets` → `dws sheet get-range` |
|
||||
| able | 多维表格 | 在线查询记录 | `dws aitable get-tables` → `dws aitable query-records` |
|
||||
| 其他(pdf/docx/txt/png 等) | 普通文件 | **不支持在线分析**,需用户主动下载后本地查看 | `dws drive download` |
|
||||
|
||||
### 下载文件到本地
|
||||
@@ -161,18 +199,27 @@ Flags:
|
||||
|
||||
用户说"我的文件/钉盘/网盘/云盘" → `list`
|
||||
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `list-spaces`
|
||||
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
|
||||
用户说"文件详情/文件信息" → `info`
|
||||
用户说"下载文件" → `download`
|
||||
用户说"新建文件夹/创建目录" → `mkdir`
|
||||
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `folder create`(文档空间)
|
||||
用户说"上传文件/传文件到钉盘" → `upload`(必须使用此命令,自动完成三步流程)
|
||||
用户说"复制文件/移动文件/搬到/移到" → 使用 `dws doc copy`/`dws doc move`(详见下方「复制/移动钉盘文件」工作流)
|
||||
用户说"复制文件/移动文件/搬到/移到" → `copy` / `move`
|
||||
用户说"重命名/改名" → `rename`
|
||||
用户说"删除文件/删除文件夹/移到回收站" → `delete`(危险操作,需确认)
|
||||
用户说"给文档授权/分享权限" → `permission add`
|
||||
|
||||
关键区分: drive(钉盘文件管理) vs doc(文档内容读写)
|
||||
关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理)
|
||||
|
||||
**drive upload vs doc upload**: 用户提到"钉盘/网盘/我的文件"→ `drive upload`;提到"知识库/文档空间/workspace"→ `doc upload`;未明确目标时默认 `drive upload`
|
||||
**drive search vs wiki node search**: 用户提到"钉盘/网盘/我的文件里搜" → `drive search`;提到"知识库/文档空间/workspace 里搜" → `wiki node search`;未明确目标时优先问明。
|
||||
|
||||
**钉盘文件复制/移动**: drive 本身没有 copy/move 命令,需使用 `dws doc copy`/`dws doc move` 实现(详见下方工作流)
|
||||
**drive upload**: 文件上传统一走 `drive upload`。上传到知识库/文档空间时加 `--workspace` 参数。
|
||||
|
||||
**drive permission vs wiki member**: "给某篇文档/文件授权" → `drive permission add`(节点级);"给某个知识库整体加成员" → `wiki member add`(空间级)
|
||||
|
||||
**创建在线文档/表格/脑图**: drive 不支持创建文件,需走 `wiki node create --type <type>`(创建空节点)或 `doc create`(创建并写入内容)。
|
||||
|
||||
**导出文档/导出为Word**: 导出是内容层操作,走 `doc export`,不属于 drive。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
@@ -201,57 +248,85 @@ dws drive upload --file ./报告.pdf --folder <dentryUuid> --format json
|
||||
dws drive delete --node <dentryUuid> --yes --format json
|
||||
```
|
||||
|
||||
## 复制/移动钉盘文件
|
||||
## 文档空间管理命令
|
||||
|
||||
钉盘本身没有 copy/move 命令,需使用 `dws doc copy`/`dws doc move` 实现。
|
||||
> 以下命令操作的是**文档空间**(知识库 / 我的文档),底层路由到 doc MCP server。
|
||||
> 与钉盘命令(list / mkdir / upload 等)的区别:钉盘命令操作钉盘空间(spaceId 纯数字),文档空间命令操作知识库/我的文档(workspaceId 加密 string)。
|
||||
|
||||
> **注意:字段选择**:`drive list` 返回中有 `dentryId`(数字格式)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`(UUID 格式,如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)**作为 `--node` 和 `--folder` 的参数值。**禁止使用 `dentryId`(数字格式,如 `220335325118`),传入数字格式会导致命令失败。**
|
||||
### 复制/移动/重命名文件
|
||||
|
||||
> **注意**:钉盘场域下,仅支持将文件复制/移动到文件夹下,不支持文档下嵌套文档。
|
||||
```
|
||||
Usage:
|
||||
dws drive copy --node <ID> [--folder <TARGET>] [--workspace <WS>]
|
||||
dws drive move --node <ID> [--folder <TARGET>] [--workspace <WS>]
|
||||
dws drive rename --node <ID> --name "新名称"
|
||||
Flags:
|
||||
--node string 文档/文件 ID 或 URL (必填)
|
||||
--folder string 目标文件夹 nodeId
|
||||
--workspace string 目标知识库 ID
|
||||
--name string 新名称 (仅 rename 必填)
|
||||
```
|
||||
|
||||
> **字段选择**:`drive list` 返回中有 `dentryId`(数字格式)和 `fileId`(UUID 格式),**必须使用 `fileId`(UUID 格式)**作为 `--node` 和 `--folder` 参数值。
|
||||
|
||||
### 创建文件夹(文档空间)
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws drive folder create --name "文件夹名"
|
||||
Flags:
|
||||
--name string 名称 (必填)
|
||||
--folder string 父文件夹 nodeId
|
||||
--workspace string 目标知识库 ID
|
||||
```
|
||||
|
||||
### 权限管理(文档节点级)
|
||||
|
||||
> 仅适用于文档空间节点,不适用于钉盘文件。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws drive permission add --node <ID> --users uid1,uid2 --role READER
|
||||
dws drive permission update --node <ID> --users uid1 --role EDITOR
|
||||
dws drive permission list --node <ID>
|
||||
dws drive permission remove --node <ID> --users uid1
|
||||
Flags:
|
||||
--node string 目标节点 ID 或 URL (必填)
|
||||
--users string 用户 userId 列表,逗号分隔
|
||||
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--limit int 返回成员数上限 (仅 list,默认 30,最大 200)
|
||||
--filter-role string 按角色过滤 (仅 list)
|
||||
```
|
||||
|
||||
> **注意**:`drive export` 不存在。导出仅对自研文档 (adoc) 有意义,属于内容层操作,应使用 `doc export`。
|
||||
|
||||
### 目标位置参数规则
|
||||
|
||||
| 目标位置 | 参数传递方式 | 前置步骤 |
|
||||
|---------|-----------|---------|
|
||||
| 未指定目标(默认) | `--folder <rootFolderId>` | 先 `dws drive list-spaces --space-type mySpace` 获取「我的文件」的 `rootFolderId` |
|
||||
| 知识库空间根目录 | `--workspace <workspaceId>` | 无需额外步骤,直接传入 workspaceId |
|
||||
| 知识库空间根目录 | `--workspace <workspaceId>` | 无需额外步骤 |
|
||||
| 钉盘 space 根目录 | `--folder <rootFolderId>` | 先 `dws drive list-spaces` 获取目标 space 的 `rootFolderId` |
|
||||
| 钉盘 space 下的子文件夹 | `--folder <fileId>` | 先 `dws drive list --space-id <spaceId>` 逐层浏览,获取目标文件夹的 `fileId`(dentryUuid 格式) |
|
||||
| 钉盘 space 下的子文件夹 | `--folder <fileId>` | 先 `dws drive list --space-id <spaceId>` 逐层浏览 |
|
||||
|
||||
### 工作流示例
|
||||
|
||||
```bash
|
||||
# ── 场景 默认: 用户未指定目标位置 → 复制/移动到「我的文件」根目录 ──
|
||||
# 1. 获取源文件 dentryUuid
|
||||
# ── 场景 默认: 复制/移动到「我的文件」根目录 ──
|
||||
dws drive list --space-id <SPACE_ID> --format json
|
||||
# 2. 获取「我的文件」个人空间的 rootFolderId
|
||||
dws drive list-spaces --space-type mySpace --format json
|
||||
# 3. 用「我的文件」的 rootFolderId 作为 --folder
|
||||
dws doc copy --node <源文件dentryUuid> --folder <我的文件rootFolderId> --format json
|
||||
dws drive copy --node <源文件dentryUuid> --folder <我的文件rootFolderId> --format json
|
||||
|
||||
# ── 场景 A: 复制钉盘文件到知识库空间根目录 ──
|
||||
# 1. 获取源文件 dentryUuid
|
||||
dws drive list --space-id <SPACE_ID> --format json
|
||||
# 2. 直接传 workspaceId 即可
|
||||
dws doc copy --node <源文件dentryUuid> --workspace <TARGET_WS_ID> --format json
|
||||
# ── 场景 A: 复制到知识库空间根目录 ──
|
||||
dws drive copy --node <源文件dentryUuid> --workspace <TARGET_WS_ID> --format json
|
||||
|
||||
# ── 场景 B: 移动钉盘文件到另一个钉盘 space 根目录 ──
|
||||
# 1. 获取源文件 dentryUuid
|
||||
dws drive list --space-id <SOURCE_SPACE_ID> --format json
|
||||
# 2. 获取目标 space 的 rootFolderId
|
||||
# ── 场景 B: 移动到另一个钉盘 space 根目录 ──
|
||||
dws drive list-spaces --format json
|
||||
# 3. 用 rootFolderId 作为 --folder(不需要传 --workspace)
|
||||
dws doc move --node <源文件dentryUuid> --folder <目标space的rootFolderId> --format json
|
||||
dws drive move --node <源文件dentryUuid> --folder <目标space的rootFolderId> --format json
|
||||
|
||||
# ── 场景 C: 复制钉盘文件到钉盘 space 下的子文件夹 ──
|
||||
# 1. 获取源文件 dentryUuid
|
||||
dws drive list --space-id <SOURCE_SPACE_ID> --format json
|
||||
# 2. 浏览目标 space 找到目标文件夹的 fileId(dentryUuid 格式)
|
||||
# ── 场景 C: 复制到钉盘子文件夹 ──
|
||||
dws drive list --space-id <TARGET_SPACE_ID> --format json
|
||||
# 若目标在更深层级,继续用 --folder 逐层浏览
|
||||
dws drive list --space-id <TARGET_SPACE_ID> --folder <父文件夹dentryUuid> --format json
|
||||
# 3. 用目标文件夹的 fileId 作为 --folder
|
||||
dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json
|
||||
dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
@@ -259,10 +334,13 @@ dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --for
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
| ------------- | ---------------------------- | -------------------------------------------------------- |
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / mkdir / delete / list 的 --node 或 --folder;`doc copy/move` 的 --node 或 --folder |
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / mkdir / delete / list 的 --node 或 --folder;`drive copy/move` 的 --node 或 --folder |
|
||||
| `list` | `spaceId` | info / download / mkdir / commit 的 --space-id |
|
||||
| `list-spaces` | `rootFolderId` | `doc copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
|
||||
| `list-spaces` | `rootFolderId` | `drive copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
|
||||
| `list-spaces` | `spaceId` | list / info / download / mkdir / upload 的 --space-id |
|
||||
| `search` | **`fileId`**(文件/文件夹结果) | info / download / delete 的 --node;list 的 --folder |
|
||||
| `search` | `spaceId` / `rootFolderId`(空间结果) | list 的 --space-id;`drive copy/move` 的 --folder |
|
||||
| `search` | `nextCursor` | search 的 --cursor(翻页) |
|
||||
| `mkdir` | `fileId`(UUID 格式) | list 的 --folder |
|
||||
|
||||
> **重要**:`drive list` 返回结果中同时包含 `dentryId` 和 `fileId` 两个字段。所有需要传 `--node` 的命令(info / download / delete)必须使用 `fileId`(即 dentryUuid),**不要使用** `dentryId`。
|
||||
@@ -273,6 +351,7 @@ dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --for
|
||||
- 不传 `--space-id` 时默认使用「我的文件」空间
|
||||
- 不传 `--folder` 时默认操作空间根目录
|
||||
- `--folder` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录;`dentryId` 只用于 `chat message send --dentry-id`
|
||||
- **`--limit` 最大值为 50**,禁止传入超过 50 的值(如 `--limit 100`)。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询,不要直接传大于 50 的值
|
||||
- `--order-by` 支持: `createTime`、`modifyTime`、`name`
|
||||
- **上传文件必须使用 `dws drive upload` 命令**,禁止使用 `upload-info` + `curl` + `commit` 三步手动流程
|
||||
- `--file-name` 必须包含扩展名(如 `report.pdf`)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
# 条件格式 (conditional format)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 条件格式
|
||||
|
||||
#### 强制走条件格式的触发词(硬约束)
|
||||
|
||||
当用户出现以下口语指令时,**强制**走 `cond-format create/update/delete`,**禁止**用 `range set-style` 写静态背景色/字体色代替:
|
||||
|
||||
- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色"
|
||||
- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分"
|
||||
- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红"
|
||||
- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变"
|
||||
- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式"
|
||||
|
||||
**判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规。
|
||||
|
||||
> 如果用 `range set-style` 写静态背景色,源数据变化时颜色不会跟着变。典型反例:用户要求"过期单元格标红"时用静态填充——日期变化后颜色不再准确。
|
||||
|
||||
**大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入。
|
||||
|
||||
#### 意图判断
|
||||
|
||||
用户说"条件格式/条件样式/自动变色/满足条件时高亮/按条件设样式/条件格式规则":
|
||||
- 查看已有条件格式 → `cond-format list`
|
||||
- 查看指定规则详情 → `cond-format list --rule-id RULE_ID`
|
||||
|
||||
用户说"创建条件格式/设置条件格式/大于某值时标红/包含某文本时高亮/数据条/图标集/色阶/重复值高亮":
|
||||
- 创建条件格式规则 → `cond-format create`
|
||||
- `--condition` 为 JSON 对象,key 为条件类型,value 为条件参数
|
||||
- `--cell-style` 为命中时的样式(适用于数值/文本/空值/错误/重复/公式/排名/平均值/标准差类型)
|
||||
- `--data-bar-style` 为数据条样式(仅数据条类型时使用)
|
||||
- 色阶和图标集类型不需要 `--cell-style`(样式内置在条件定义中)
|
||||
|
||||
用户说"修改条件格式/更新条件格式规则/改条件/改样式/改范围":
|
||||
- 更新条件格式规则 → `cond-format update`
|
||||
- 未传入的字段保持原有值不变
|
||||
- 传入 `--condition` 会替换原有条件类型
|
||||
|
||||
用户说"删除条件格式/移除条件格式/取消条件格式":
|
||||
- 删除条件格式规则 → `cond-format delete --yes`
|
||||
- 删除不可恢复,执行前必须向用户确认,同意后才加 `--yes` 执行
|
||||
- 规则已不存在时操作仍返回成功
|
||||
|
||||
#### 常见配置错误(必须注意)
|
||||
|
||||
- **创建后必须验证**:条件格式创建后必须调用 `cond-format list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试
|
||||
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏也不要过度扩大
|
||||
- **`backgroundColor` vs `fontColor` 的中文语义**:用户中文语境下的"标红/高亮/染色/标记"指**单元格背景色**,用 `backgroundColor`;"文字红/字体红/把字变红"才用 `fontColor`。默认无说明时选 `backgroundColor`
|
||||
- **日期/空值比较必须防空**:用户说"过期的标红"时,公式必须排除空单元格,否则空白格也会被误判为过期而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
|
||||
- **公式引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 使用相对引用使公式随行变化,而非 `=$E$1<=TODAY()` 只比较一个格)
|
||||
- **创建前必须确认列对应**:仅读表头不够——如果表头语义含糊,formula 里引用的列字母可能张冠李戴。建议先读 3-5 行数据样本(如 `range read --range "A1:Z5"`)确认列名对应的实际值和数据类型
|
||||
|
||||
#### 辅助列+条件格式两步走(高频致命错误防护)
|
||||
|
||||
**用户明确要求"辅助列+条件格式"两步走时,禁止用 `formulaCondition` 绕过**:
|
||||
|
||||
当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `formulaCondition` 公式一步完成:
|
||||
- "**增加辅助列**,再/然后标记……"
|
||||
- "**先计算/判断** XX **是否** YY,**再**标记……"
|
||||
- "**新建一列**放结果,再用结果染色"
|
||||
- 明确要求用"辅助列"、"辅助字段"、"判断列"、"标记列"
|
||||
|
||||
**正确做法(两步走)**:
|
||||
```bash
|
||||
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列)
|
||||
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H100" \
|
||||
--values '[["=IF(A2>B2,\"是\",\"否\")"],...]'
|
||||
|
||||
# Step 2: 基于辅助列值做条件格式(用 formulaCondition 引用辅助列)
|
||||
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
|
||||
--ranges '["A2:H100"]' \
|
||||
--condition '{"formulaCondition":{"formula":"=$H2=\"是\""}}' \
|
||||
--cell-style '{"backgroundColor":"#FFECEC"}'
|
||||
```
|
||||
|
||||
**错误做法(一步走绕过辅助列)**:
|
||||
```bash
|
||||
# 虽然逻辑等价,但产物里缺辅助列 → 用户打开表格看不到"是/否"列
|
||||
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
|
||||
--ranges '["A2:H100"]' \
|
||||
--condition '{"formulaCondition":{"formula":"=$A2>$B2"}}' \
|
||||
--cell-style '{"backgroundColor":"#FFECEC"}'
|
||||
```
|
||||
|
||||
**为什么禁止一步走**:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到判断结果列;条件格式只是视觉辅助。一步 `formulaCondition` 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整"。
|
||||
|
||||
> `formulaCondition` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。
|
||||
|
||||
#### 典型工作流
|
||||
|
||||
```
|
||||
1. 先读取现有条件格式了解当前配置
|
||||
dws sheet cond-format list --node NODE_ID --sheet-id SHEET_ID
|
||||
|
||||
2. 创建/更新/删除条件格式规则
|
||||
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID --ranges '["A1:E100"]' \
|
||||
--condition '{"numberCondition":{"operator":"greater","value1":"80"}}' \
|
||||
--cell-style '{"backgroundColor":"#FFCDD2","fontColor":"#B71C1C","bold":true}'
|
||||
|
||||
3. 再次读取验证结果是否生效
|
||||
dws sheet cond-format list --node NODE_ID --sheet-id SHEET_ID
|
||||
```
|
||||
|
||||
#### 条件类型参考
|
||||
|
||||
条件类型(`--condition` JSON 的 key,每次只能选一种):
|
||||
|
||||
| 条件类型 | 说明 | 参数 |
|
||||
|---------|------|------|
|
||||
| `numberCondition` | 数值比较 | operator: equal/not-equal/greater/greater-equal/less/less-equal/between/not-between + value1 + value2 |
|
||||
| `textCondition` | 文本匹配 | operator: contains/not-contains/starts-with/ends-with + value |
|
||||
| `emptyCondition` | 空值判断 | operator: is-empty/is-not-empty |
|
||||
| `errorCondition` | 错误值 | operator: error/no-error |
|
||||
| `duplicateCondition` | 重复/唯一值 | operator: duplicate/unique |
|
||||
| `formulaCondition` | 自定义公式 | formula: "=A1>100" |
|
||||
| `rankCondition` | 排名 | value + isPercent + isBottom |
|
||||
| `averageCondition` | 高于/低于平均值 | isAbove + andEqual |
|
||||
| `stdevCondition` | 标准差 | value + isAbove + andEqual |
|
||||
| `dataBarCondition` | 数据条 | minPoint + maxPoint(每个含 type + value) |
|
||||
| `iconSetCondition` | 图标集 | iconSet(数组)+ showIconOnly |
|
||||
| `colorScaleCondition` | 色阶 | criterias(数组,每项含 type + value + color) |
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 获取条件格式规则
|
||||
```
|
||||
Usage:
|
||||
dws sheet cond-format list [flags]
|
||||
Example:
|
||||
# 获取所有条件格式规则
|
||||
dws sheet cond-format list --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
|
||||
# 获取单个规则的详情
|
||||
dws sheet cond-format list --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--rule-id string 条件格式规则 ID (可选,不传则返回全部)
|
||||
```
|
||||
|
||||
- **用途**:查看指定工作表中已有的条件格式规则,或获取单个规则的详情。
|
||||
- **场景**:创建/更新/删除条件格式前后验证规则状态;获取 ruleId 供后续 update/delete 使用。
|
||||
- **返回**:rules 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
|
||||
|
||||
### 创建条件格式规则
|
||||
```
|
||||
Usage:
|
||||
dws sheet cond-format create [flags]
|
||||
Example:
|
||||
# 数值条件:大于 80 时标红加粗
|
||||
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--ranges '["A1:A100"]' \
|
||||
--condition '{"numberCondition":{"operator":"greater","value1":"80"}}' \
|
||||
--cell-style '{"backgroundColor":"#FFCDD2","fontColor":"#B71C1C","bold":true}'
|
||||
|
||||
# 文本条件:包含"延期"时加删除线
|
||||
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--ranges '["B1:B50"]' \
|
||||
--condition '{"textCondition":{"operator":"contains","value":"延期"}}' \
|
||||
--cell-style '{"backgroundColor":"#FFF3E0","strikethrough":true}'
|
||||
|
||||
# 数据条
|
||||
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--ranges '["C1:C20"]' \
|
||||
--condition '{"dataBarCondition":{"minPoint":{"type":"auto"},"maxPoint":{"type":"auto"}}}' \
|
||||
--data-bar-style '{"fill":["#4CAF50","#F44336"],"isGradient":true}'
|
||||
|
||||
# 色阶(三色)
|
||||
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--ranges '["D1:D50"]' \
|
||||
--condition '{"colorScaleCondition":{"criterias":[{"type":"maxmin","color":"#F44336"},{"type":"percentile","value":"50","color":"#FFEB3B"},{"type":"maxmin","color":"#4CAF50"}]}}'
|
||||
|
||||
# 重复值高亮
|
||||
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--ranges '["E1:E100"]' \
|
||||
--condition '{"duplicateCondition":{"operator":"duplicate"}}' \
|
||||
--cell-style '{"backgroundColor":"#FCE4EC"}'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--ranges string 应用范围 JSON 数组 (必填),如 '["A1:E10"]'
|
||||
--condition string 条件类型及参数 JSON 对象 (必填)
|
||||
--cell-style string 单元格样式 JSON 对象 (可选)
|
||||
--data-bar-style string 数据条样式 JSON 对象 (可选,仅数据条类型)
|
||||
```
|
||||
|
||||
- **用途**:在指定工作表中创建一条条件格式规则。
|
||||
- **注意事项**:
|
||||
- 创建后**必须**用 `cond-format list` 验证规则是否生效
|
||||
- 中文"标红/高亮/染色"默认指 `backgroundColor`,"字体红"才是 `fontColor`
|
||||
- 日期/空值公式必须防空:`=AND(E1<>"", E1<=TODAY())` 而非 `=E1<=TODAY()`
|
||||
- 公式中用相对引用使公式随行变化
|
||||
- 创建前建议先 `range read` 读 3-5 行数据确认列对应关系
|
||||
- **条件类型**(`--condition` JSON 的 key,每次只能选一种):numberCondition / textCondition / emptyCondition / errorCondition / duplicateCondition / formulaCondition / rankCondition / averageCondition / stdevCondition / dataBarCondition / iconSetCondition / colorScaleCondition
|
||||
|
||||
### 更新条件格式规则
|
||||
```
|
||||
Usage:
|
||||
dws sheet cond-format update [flags]
|
||||
Example:
|
||||
# 修改条件(改为大于 90)
|
||||
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
|
||||
--condition '{"numberCondition":{"operator":"greater","value1":"90"}}'
|
||||
|
||||
# 修改样式
|
||||
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
|
||||
--cell-style '{"backgroundColor":"#C8E6C9","fontColor":"#1B5E20"}'
|
||||
|
||||
# 修改应用范围
|
||||
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
|
||||
--ranges '["A1:F200"]'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--rule-id string 条件格式规则 ID (必填)
|
||||
--ranges string 应用范围 JSON 数组 (可选)
|
||||
--condition string 条件类型及参数 JSON 对象 (可选,传入后替换原有条件)
|
||||
--cell-style string 单元格样式 JSON 对象 (可选)
|
||||
--data-bar-style string 数据条样式 JSON 对象 (可选,仅数据条类型)
|
||||
```
|
||||
|
||||
- **用途**:更新已有条件格式规则的部分或全部配置。
|
||||
- **场景**:修改阈值、切换条件类型、调整样式、扩大应用范围。
|
||||
- **注意**:未传入的字段保持原有值不变;`--ranges`/`--condition`/`--cell-style`/`--data-bar-style` 至少传入一个。
|
||||
- **ruleId 获取**:通过 `cond-format list` 获取。
|
||||
|
||||
### 删除条件格式规则
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws sheet cond-format delete [flags]
|
||||
Example:
|
||||
# 删除条件格式规则(必须加 --yes 确认)
|
||||
dws sheet cond-format delete --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> --yes
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--rule-id string 条件格式规则 ID (必填)
|
||||
```
|
||||
|
||||
- **用途**:删除指定条件格式规则。
|
||||
- **幂等性**:规则已不存在时操作仍返回成功。
|
||||
- **ruleId 获取**:通过 `cond-format list` 获取。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `cond-format list` | `rules` 数组(含 `id`、`type`、`ranges`、条件参数、`cellStyle`/`dataBarStyle`) | 获取 ruleId 供 update / delete 使用;验证规则是否生效 |
|
||||
| `cond-format create` | 新创建规则的 `id` | 用于后续 update / delete 的 --rule-id |
|
||||
| `cond-format update` | 更新后的规则信息 | 确认更新结果 |
|
||||
| `cond-format delete` | 操作结果 | 确认删除完成 |
|
||||
| `list` | 工作表的 `sheetId` | cond-format list / create / update / delete 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- ★ **强制走条件格式的触发词**:用户说"标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变"时,强制走 `cond-format create/update/delete`,禁止用 `range set-style` 写静态背景色代替
|
||||
- **创建后必须验证**:条件格式创建后必须调用 `cond-format list` 验证规则是否生效
|
||||
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏也不要过度扩大
|
||||
- **`backgroundColor` vs `fontColor` 的中文语义**:用户中文语境下的"标红/高亮/染色/标记"指**单元格背景色**,用 `backgroundColor`;"文字红/字体红/把字变红"才用 `fontColor`。默认无说明时选 `backgroundColor`
|
||||
- **日期/空值比较必须防空**:用户说"过期的标红"时,公式必须排除空单元格。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
|
||||
- **公式引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 使用相对引用使公式随行变化)
|
||||
- **创建前必须确认列对应**:仅读表头不够——如果表头语义含糊,formula 里引用的列字母可能张冠李戴。建议先读 3-5 行数据样本(如 `range read --range "A1:Z5"`)确认列名对应的实际值和数据类型
|
||||
- **辅助列+条件格式两步走**:用户明确要求"辅助列"时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),禁止直接用 `formulaCondition` 一步绕过
|
||||
- **大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入
|
||||
- **判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规
|
||||
@@ -0,0 +1,275 @@
|
||||
# 行列操作 (dimension operations)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 行列操作
|
||||
|
||||
用户说"插入行/插入列/在某行前插入/在某列前插入":
|
||||
- 插入行或列 → `insert-dimension`
|
||||
- 在末尾追加 → `append`(insert-dimension 不支持末尾追加)
|
||||
|
||||
用户说"删除行/删除列/删掉第几行/删掉某列/移除行/移除列":
|
||||
- 删除行或列 → `delete-dimension`
|
||||
- 仅清空内容但保留行/列 → `range clear`(默认清除值保留格式)
|
||||
|
||||
用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性":
|
||||
- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false`
|
||||
- 设置行高/列宽 → `update-dimension --pixel-size`
|
||||
- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden`
|
||||
|
||||
用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行":
|
||||
- 移动行或列 → `move-dimension`
|
||||
- 请勿用 `range read` + `range update` 读取再重写来模拟移动,`move-dimension` 是原子操作,能保留格式和合并状态
|
||||
|
||||
用户说"追加空行/追加空列/增加行数/增加列数/扩展表格/在末尾加空行":
|
||||
- 追加空行/空列 → `add-dimension`
|
||||
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
|
||||
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
|
||||
|
||||
**结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 在指定位置插入行或列
|
||||
```
|
||||
Usage:
|
||||
dws sheet insert-dimension [flags]
|
||||
Example:
|
||||
# 在第 3 行之前插入 2 行
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "3" --length 2
|
||||
|
||||
# 在 A 列之前插入 1 列
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "A" --length 1
|
||||
|
||||
# 使用工作表前缀(忽略 --sheet-id)
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "Sheet1!3" --length 5
|
||||
|
||||
# 在 AB 列之前插入 3 列
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "AB" --length 3
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--dimension string 插入维度: ROWS 或 COLUMNS (必填)
|
||||
--position string 插入位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
|
||||
--length string 插入数量,正整数 (必填),最大 5000
|
||||
```
|
||||
|
||||
在钉钉表格指定工作表的指定位置之前插入若干空行或空列。
|
||||
`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。
|
||||
支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
|
||||
若需要在末尾追加行/列,请使用 `append` 命令。
|
||||
|
||||
### 删除指定位置的行或列
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws sheet delete-dimension [flags]
|
||||
Example:
|
||||
# 从第 3 行开始删除 2 行
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "3" --length 2
|
||||
|
||||
# 从 A 列开始删除 1 列
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "A" --length 1
|
||||
|
||||
# 使用工作表前缀(忽略 --sheet-id)
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "Sheet1!3" --length 5
|
||||
|
||||
# 从 AB 列开始删除 3 列
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "AB" --length 3
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--dimension string 删除维度: ROWS 或 COLUMNS (必填)
|
||||
--position string 删除起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
|
||||
--length string 删除数量,正整数 (必填),最大 5000
|
||||
```
|
||||
|
||||
在钉钉表格指定工作表中,从指定位置起删除若干连续的行或列。
|
||||
`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。
|
||||
支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
|
||||
删除后后续的行/列会向前移动填补空位;若需要仅清空内容但保留行/列占位,请使用 `range clear`。
|
||||
|
||||
### 更新指定范围行/列属性
|
||||
```
|
||||
Usage:
|
||||
dws sheet update-dimension [flags]
|
||||
Example:
|
||||
# 隐藏第 3~4 行
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "3" --length 2 --hidden
|
||||
|
||||
# 显示 A~B 列
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --start-index "A" --length 2 --hidden=false
|
||||
|
||||
# 设置第 1~5 行行高为 40px
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "1" --length 5 --pixel-size 40
|
||||
|
||||
# 设置 C 列列宽为 200px 并隐藏
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden
|
||||
|
||||
# 使用工作表前缀(忽略 --sheet-id)
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--dimension string 更新维度: ROWS 或 COLUMNS (必填)
|
||||
--start-index string 起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
|
||||
--length string 更新数量,正整数 (必填),最大 5000
|
||||
--hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一
|
||||
--pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一
|
||||
```
|
||||
|
||||
批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。
|
||||
`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。
|
||||
支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
|
||||
`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。
|
||||
|
||||
### 移动行或列
|
||||
```
|
||||
Usage:
|
||||
dws sheet move-dimension [flags]
|
||||
Example:
|
||||
# 将第 2 行移动到第 5 行的位置
|
||||
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --start-index "2" --end-index "2" --destination-index "5"
|
||||
|
||||
# 将第 2~4 行(共 3 行)移动到第 1 行的位置(最前面)
|
||||
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --start-index "2" --end-index "4" --destination-index "1"
|
||||
|
||||
# 将 B~C 列(共 2 列)移动到 D 列的位置
|
||||
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension COLUMNS --start-index "B" --end-index "C" --destination-index "D"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--dimension string 维度类型: ROWS 或 COLUMNS (必填)
|
||||
--start-index string 源起始位置,A1 表示法 (必填)
|
||||
--end-index string 源结束位置,A1 表示法 (必填)
|
||||
--destination-index string 目标位置,A1 表示法 (必填)
|
||||
```
|
||||
|
||||
startIndex、endIndex 和 destinationIndex 均使用 A1 表示法:`--dimension ROWS` 时为 1-based 行号(如 "2"),`--dimension COLUMNS` 时为列字母(如 "B")。
|
||||
源行/列将移动到 destinationIndex 所指的位置。destinationIndex 不能落在源范围 [startIndex, endIndex] 内。
|
||||
|
||||
**合并单元格注意**:如果源范围或目标位置涉及合并单元格,操作会报错中断。移动前先通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查询 `mergedRanges`,必要时先用 `unmerge-cells` 取消合并再移动,移动后再用 `merge-cells` 恢复需要保留的合并区域。
|
||||
|
||||
### 追加空行或空列
|
||||
```
|
||||
Usage:
|
||||
dws sheet add-dimension [flags]
|
||||
Example:
|
||||
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --length 5
|
||||
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --length 3
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--dimension string 维度类型: ROWS 或 COLUMNS (必填)
|
||||
--length int 追加数量,正整数,最多 5000 (必填)
|
||||
```
|
||||
|
||||
在工作表末尾追加指定数量的空行或空列。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 6: 插入行或列 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 在第 3 行之前插入 2 行
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --position "3" --length 2 --format json
|
||||
|
||||
# 3. 在 A 列之前插入 1 列
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension COLUMNS --position "A" --length 1 --format json
|
||||
|
||||
# 4. 使用工作表前缀指定位置
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --position "Sheet1!5" --length 3 --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
# ── 工作流 6b: 删除行或列 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 从第 3 行开始删除 2 行
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --position "3" --length 2 --format json
|
||||
|
||||
# 3. 从 A 列开始删除 1 列
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension COLUMNS --position "A" --length 1 --format json
|
||||
|
||||
# 4. 使用工作表前缀指定位置
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --position "Sheet1!5" --length 3 --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
# ── 工作流 6c: 更新行/列属性(显隐、行高/列宽) ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 隐藏第 3~4 行
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --start-index "3" --length 2 --hidden --format json
|
||||
|
||||
# 3. 显示 A~B 列
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension COLUMNS --start-index "A" --length 2 --hidden=false --format json
|
||||
|
||||
# 4. 设置第 1~5 行行高为 40px
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension ROWS --start-index "1" --length 5 --pixel-size 40 --format json
|
||||
|
||||
# 5. 设置 C 列列宽为 200px 并隐藏
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden --format json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `insert-dimension` | `a1Notation` 新插入区域范围 | 确认插入位置和范围 |
|
||||
| `delete-dimension` | `a1Notation` 被删除区域范围 | 确认删除位置和范围 |
|
||||
| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 |
|
||||
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- `sheet info` 的 `mergedRanges` 是行列结构操作的重要预检信息。插入列时尤其要检查多行表头合并区,原有合并区域通常不会自动扩展到新列,必要时需重新设置合并区域
|
||||
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`
|
||||
- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
|
||||
- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
|
||||
- `insert-dimension` 的 `--length` 最大为 5000
|
||||
- `delete-dimension` 从指定位置起删除若干连续的行或列,删除后后续行/列向前移动填补空位
|
||||
- `delete-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
|
||||
- `delete-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
|
||||
- `delete-dimension` 的 `--length` 最大为 5000
|
||||
- `delete-dimension` 若需仅清空内容但保留行/列占位,请使用 `range clear`(默认清除值保留格式,比手动构造空数组更简洁)
|
||||
- `update-dimension` 批量更新连续行/列的显隐状态与行高/列宽
|
||||
- `update-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
|
||||
- `update-dimension` 的 `--start-index` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
|
||||
- `update-dimension` 的 `--length` 最大为 5000
|
||||
- `update-dimension` 的 `--hidden` 与 `--pixel-size` 至少必须提供一个
|
||||
- `update-dimension` 的 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽
|
||||
- `update-dimension` 当同时提供 `--hidden` 与 `--pixel-size` 时,将先应用尺寸再应用显隐,任一失败整体失败
|
||||
- ★ `move-dimension` vs `range update`:需要移动行或列时,必须使用 `move-dimension` 命令,禁止用 `range update` 读取数据后手动重写来模拟移动效果。`move-dimension` 是原子操作,能保留单元格的格式、合并状态等属性
|
||||
- `move-dimension` 的 `--start-index`、`--end-index` 和 `--destination-index` 均使用 A1 表示法(ROWS 时为 1-based 行号,COLUMNS 时为列字母)
|
||||
- `move-dimension` 的 `--destination-index` 不能落在源范围 [startIndex, endIndex] 内
|
||||
- `move-dimension` 的源范围 [startIndex, endIndex] 最大跨度为 5000
|
||||
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
|
||||
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
|
||||
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
|
||||
@@ -0,0 +1,94 @@
|
||||
# 下拉列表 (dropdown)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 下拉列表
|
||||
|
||||
用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉":
|
||||
- 设置下拉列表 → `set-dropdown`
|
||||
- 设置多选下拉 → `set-dropdown --multi-select`
|
||||
|
||||
用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项":
|
||||
- 获取下拉列表配置 → `get-dropdown`
|
||||
|
||||
用户说"删除下拉列表/移除下拉/取消下拉/清除下拉":
|
||||
- 删除下拉列表 → `delete-dropdown`
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 设置下拉列表
|
||||
```
|
||||
Usage:
|
||||
dws sheet set-dropdown [flags]
|
||||
Example:
|
||||
# 设置单选下拉列表
|
||||
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100" \
|
||||
--options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]'
|
||||
|
||||
# 设置带颜色的多选下拉列表
|
||||
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B50" \
|
||||
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
|
||||
--multi-select
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
|
||||
--options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'
|
||||
--multi-select 是否允许多选(默认单选)
|
||||
```
|
||||
|
||||
在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。
|
||||
- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。
|
||||
- **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。
|
||||
- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。
|
||||
|
||||
### 获取下拉列表配置
|
||||
```
|
||||
Usage:
|
||||
dws sheet get-dropdown [flags]
|
||||
Example:
|
||||
dws sheet get-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100"
|
||||
dws sheet get-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
|
||||
```
|
||||
|
||||
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
|
||||
- **用途**:查看单元格已设置的下拉列表选项和配置。
|
||||
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
|
||||
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
|
||||
|
||||
### 删除下拉列表
|
||||
```
|
||||
Usage:
|
||||
dws sheet delete-dropdown [flags]
|
||||
Example:
|
||||
dws sheet delete-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100"
|
||||
dws sheet delete-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B1:D10"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 要删除下拉列表的范围,A1 表示法 (必填)
|
||||
```
|
||||
|
||||
删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。
|
||||
- **用途**:移除不再需要的下拉列表约束。
|
||||
- **注意**:已填写的单元格值不会被清除;目标范围不存在下拉列表时操作仍返回成功。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 |
|
||||
| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 |
|
||||
| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
|
||||
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
|
||||
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
|
||||
@@ -0,0 +1,95 @@
|
||||
# 导出 (export)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 导出
|
||||
|
||||
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
|
||||
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
|
||||
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
|
||||
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
|
||||
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
|
||||
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 导出表格为 xlsx(异步任务一站式)
|
||||
```
|
||||
Usage:
|
||||
dws sheet export [flags] # 一站式:提交 → 轮询 → 可选下载
|
||||
Example:
|
||||
# 仅导出,返回 downloadUrl(链接有时效性,请尽快下载)
|
||||
dws sheet export --node <NODE_ID>
|
||||
dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
|
||||
# 导出并自动下载为本地文件
|
||||
dws sheet export --node <NODE_ID> --output ./report.xlsx
|
||||
|
||||
# --output 为目录时,自动按下载链接中的文件名保存
|
||||
dws sheet export --node <NODE_ID> --output ./
|
||||
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--output string 本地保存路径(可选,支持文件路径或目录)
|
||||
```
|
||||
|
||||
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
|
||||
|
||||
**内部流程**:
|
||||
1. 调 `submit_export_job` 获取 `jobId`
|
||||
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
|
||||
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
|
||||
|
||||
**内置轮询策略(CLI 内实现,无需关心)**:
|
||||
- 第 1~5 次:每次间隔 2 秒
|
||||
- 第 6~10 次:每次间隔 5 秒
|
||||
- 第 11~20 次:每次间隔 10 秒
|
||||
- 第 21~30 次:每次间隔 15 秒
|
||||
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
|
||||
|
||||
**命令返回**:
|
||||
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
|
||||
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
|
||||
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
|
||||
|
||||
**失败处理(命令内部已处理,Agent 仅需转述)**:
|
||||
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
|
||||
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
|
||||
|
||||
**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
|
||||
|
||||
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
|
||||
dws sheet export --node <NODE_ID> --format json
|
||||
# 传入 URL 也可:
|
||||
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
|
||||
|
||||
# 场景 B:导出并自动下载为本地文件
|
||||
dws sheet export --node <NODE_ID> --output ./report.xlsx
|
||||
|
||||
# 场景 C:下载到目录,自动按链接推断文件名
|
||||
dws sheet export --node <NODE_ID> --output ./
|
||||
|
||||
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
|
||||
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `export` | `downloadUrl`(未指定 --output)/ `导出完成: <path>`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
|
||||
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
|
||||
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
|
||||
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
|
||||
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
|
||||
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
|
||||
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
|
||||
@@ -0,0 +1,344 @@
|
||||
# 筛选视图 (filter-view)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 筛选视图
|
||||
|
||||
用户说"筛选视图/查看筛选视图/有哪些筛选视图/筛选视图列表":
|
||||
- 获取所有筛选视图 → `filter-view list`
|
||||
|
||||
用户说"筛选视图详情/查看某个筛选视图/筛选视图信息/筛选视图配置":
|
||||
- 获取单个筛选视图详情 → `filter-view info`
|
||||
|
||||
用户说"创建筛选视图/新建筛选视图/添加筛选视图":
|
||||
- 创建筛选视图 → `filter-view create`
|
||||
|
||||
用户说"更新筛选视图/修改筛选视图/改筛选视图名称/改筛选视图范围":
|
||||
- 更新筛选视图属性 → `filter-view update`
|
||||
|
||||
用户说"删除筛选视图/移除筛选视图":
|
||||
- 删除筛选视图 → `filter-view delete`
|
||||
|
||||
用户说"设置筛选条件/添加筛选条件/配置筛选视图条件/按值筛选/按条件筛选/按颜色筛选":
|
||||
- 设置筛选视图列条件 → `filter-view update-criteria`
|
||||
|
||||
用户说"查看筛选条件/有哪些筛选条件/筛选视图设了什么条件/列出筛选条件":
|
||||
- 列出所有列条件 → `filter-view list-criteria`
|
||||
- 查看某一列的条件 → `filter-view get-criteria --column N`
|
||||
|
||||
用户说"清除筛选条件/移除筛选条件/取消筛选条件":
|
||||
- 清除筛选视图列条件 → `filter-view delete-criteria`
|
||||
- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 获取所有筛选视图
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view list [flags]
|
||||
Example:
|
||||
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
dws sheet filter-view list --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --sheet-id "Sheet1"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
```
|
||||
|
||||
获取指定工作表的所有筛选视图列表,返回每个筛选视图的 ID、名称和范围信息。
|
||||
- **用途**:查看当前工作表上已创建的所有筛选视图,获取视图 ID、名称和范围。
|
||||
- **场景**:在对筛选视图进行 update / delete / update-criteria 等操作前,先用 list 获取可用的 filterViewId。
|
||||
- **区分**:筛选视图(filter-view)是个人化的数据过滤方式,与全局筛选不同。每个用户可以创建自己的筛选视图,互不影响原始数据。如果没有筛选视图,返回空列表。
|
||||
|
||||
### 创建筛选视图
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view create [flags]
|
||||
Example:
|
||||
# 创建不带筛选条件的筛选视图
|
||||
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "我的视图" --range "A1:E10"
|
||||
|
||||
# 创建带按值筛选条件的筛选视图
|
||||
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "销售筛选" --range "A1:E10" \
|
||||
--criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]}]'
|
||||
|
||||
# 创建带按条件筛选的筛选视图(大于等于 200000)
|
||||
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "高预算" --range "A1:C10" \
|
||||
--criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}]'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--name string 筛选视图名称 (必填)
|
||||
--range string 筛选视图范围,A1 表示法,如 A1:E10 (必填)
|
||||
--criteria string 筛选条件,JSON 数组 (可选)
|
||||
```
|
||||
|
||||
在指定工作表中创建一个筛选视图。
|
||||
- **用途**:为指定数据区域创建一个可命名的个人化筛选视图,可选同时设置筛选条件。
|
||||
- **场景**:用户需要针对某个数据区域建立固定的筛选视角(如"高绩效员工""研发部数据"),方便反复查看。
|
||||
- **区分**:与全局筛选不同,筛选视图是个人化的,不影响其他用户看到的数据。如果只需创建视图不设条件,后续可通过 `update-criteria` 单独设置;如果要一步到位,可通过 `--criteria` 在创建时直接设置。
|
||||
`--criteria` 为 JSON 数组,每个元素包含 `column`(列偏移量,从 0 开始)和筛选条件字段。支持三种筛选类型:
|
||||
- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表
|
||||
- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符(kebab-case):`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或)
|
||||
- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一
|
||||
|
||||
### 更新筛选视图属性
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view update [flags]
|
||||
Example:
|
||||
# 更新筛选视图名称
|
||||
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --name "新名称"
|
||||
|
||||
# 更新筛选视图范围
|
||||
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --range "A1:F20"
|
||||
|
||||
# 更新筛选条件
|
||||
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
--name string 筛选视图新名称
|
||||
--range string 筛选视图新范围,A1 表示法
|
||||
--criteria string 筛选条件,JSON 数组
|
||||
```
|
||||
|
||||
更新筛选视图的名称、范围和/或筛选条件,`--name`、`--range`、`--criteria` 至少传入一个。
|
||||
- **用途**:修改已有筛选视图的名称、数据范围或筛选条件。
|
||||
- **场景**:数据区域扩展后需要扩大筛选视图范围,或重命名视图,或通过 `--criteria` 一次性批量更新多列筛选条件。
|
||||
- **区分**:`update` 可同时修改名称、范围和条件,适合批量更新;`update-criteria` 只能设置单列条件,适合精确控制某一列的筛选逻辑。`--criteria` 指定列的条件会被替换,未指定的列保持不变。
|
||||
|
||||
`--criteria` 为 JSON 数组,格式与 `filter-view create` 的 `--criteria` 相同,支持的筛选类型和操作符参见「创建筛选视图」说明。
|
||||
|
||||
### 删除筛选视图
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view delete [flags]
|
||||
Example:
|
||||
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
```
|
||||
|
||||
删除指定的筛选视图。
|
||||
- **用途**:永久删除一个不再需要的筛选视图及其所有筛选条件。
|
||||
- **场景**:筛选视图已过时或不再需要时,清理无用的视图。
|
||||
- **区分**:`delete` 删除整个筛选视图(包括所有列的条件),操作不可恢复;`delete-criteria` 只删除某一列的筛选条件,视图本身保留。此操作不影响全局筛选或其他筛选视图,也不影响原始数据。
|
||||
|
||||
### 更新筛选视图列条件
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view update-criteria [flags]
|
||||
Example:
|
||||
# 按值筛选:只显示"销售部"和"市场部"
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}'
|
||||
|
||||
# 按条件筛选:大于 100
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}'
|
||||
|
||||
# 按条件筛选:大于等于 200000
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}'
|
||||
|
||||
# 按条件筛选:小于 100
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"less","value":"100"}]}'
|
||||
|
||||
# 多条件筛选:大于等于 60 且 小于等于 90
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 2 --filter-criteria '{"filterType":"condition","conditionOperator":"and","conditions":[{"operator":"greater-equal","value":"60"},{"operator":"less-equal","value":"90"}]}'
|
||||
|
||||
# 按颜色筛选:背景色为红色
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 1 --filter-criteria '{"filterType":"color","backgroundColor":"#FF0000"}'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
--column int 列偏移量,从 0 开始 (必填)
|
||||
--filter-criteria string 筛选条件,JSON 对象 (必填)
|
||||
```
|
||||
|
||||
更新筛选视图中某一列的筛选条件。
|
||||
- **用途**:为筛选视图的指定列创建或更新筛选条件,控制该列哪些数据行可见。
|
||||
- **场景**:只显示某些特定值的行(如"只看研发部")→ `filterType: values`;按数值条件筛选(如"绩效 ≥ 85")→ `filterType: condition` + `operator: greater-equal`;按文本条件筛选(如"名称包含关键字")→ `filterType: condition` + `operator: contains`。
|
||||
- **区分**:`update-criteria` 精确控制单列条件,适合逐列设置不同的筛选逻辑;`filter-view update --criteria` 可以批量更新多列条件;`delete-criteria` 是 `update-criteria` 的逆操作,删除指定列的条件。
|
||||
|
||||
`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
|
||||
例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列,`--column 1` 代表 C 列。
|
||||
|
||||
`--filter-criteria` 为 JSON 对象,支持三种筛选类型:
|
||||
- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表
|
||||
- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符:`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或)
|
||||
- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一
|
||||
|
||||
### 删除筛选视图列条件
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view delete-criteria [flags]
|
||||
Example:
|
||||
# 删除第 1 列(A 列)的筛选条件
|
||||
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 0
|
||||
|
||||
# 删除第 3 列(C 列)的筛选条件
|
||||
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 2
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
--column int 列偏移量,从 0 开始 (必填)
|
||||
```
|
||||
|
||||
清除筛选视图中指定列的筛选条件。
|
||||
- **用途**:移除筛选视图中指定列的筛选条件,使该列不再参与过滤。
|
||||
- **场景**:之前通过 `update-criteria` 设置了某列的筛选条件,现在需要取消该列的筛选以显示全部数据。
|
||||
- **区分**:`delete-criteria` 只清除指定列的条件,筛选视图本身和其他列的条件保持不变;`delete` 会删除整个筛选视图。如果指定列没有设置筛选条件,调用此命令不会报错(幂等操作)。
|
||||
|
||||
### 获取单个筛选视图详情
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view info [flags]
|
||||
Example:
|
||||
# 查看指定筛选视图的详情
|
||||
dws sheet filter-view info --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
```
|
||||
|
||||
获取指定筛选视图的完整信息,包括 ID、名称、范围和筛选条件。
|
||||
- **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。
|
||||
- **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。
|
||||
- **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。
|
||||
- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。
|
||||
|
||||
### 列出筛选视图所有列条件
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view list-criteria [flags]
|
||||
Example:
|
||||
# 列出筛选视图的所有条件
|
||||
dws sheet filter-view list-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
```
|
||||
|
||||
列出指定筛选视图中已设置的所有列筛选条件。
|
||||
- **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。
|
||||
- **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。
|
||||
- **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。
|
||||
- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。
|
||||
|
||||
### 获取单列筛选条件
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter-view get-criteria [flags]
|
||||
Example:
|
||||
# 查看第 1 列(偏移量 0)的筛选条件
|
||||
dws sheet filter-view get-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 0
|
||||
|
||||
# 查看第 3 列(偏移量 2)的筛选条件
|
||||
dws sheet filter-view get-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 2
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--filter-view-id string 筛选视图 ID (必填)
|
||||
--column int 列偏移量,从 0 开始 (必填)
|
||||
```
|
||||
|
||||
获取指定筛选视图中某一列的筛选条件详情。
|
||||
- **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。
|
||||
- **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。
|
||||
- **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
|
||||
- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 11: 筛选视图管理 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> -f json
|
||||
|
||||
# 2. 查看已有筛选视图
|
||||
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID> -f json
|
||||
|
||||
# 3. 创建筛选视图(不带条件)
|
||||
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--name "我的筛选" --range "A1:E100" -f json
|
||||
|
||||
# 4. 为筛选视图设置列条件(按值筛选)
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' -f json
|
||||
|
||||
# 5. 为筛选视图设置列条件(按条件筛选)
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' -f json
|
||||
|
||||
# 6. 更新筛选视图名称和范围
|
||||
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--name "销售数据筛选" --range "A1:F200" -f json
|
||||
|
||||
# 7. 清除某列的筛选条件
|
||||
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
|
||||
--column 0 -f json
|
||||
|
||||
# 8. 删除筛选视图
|
||||
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> -f json
|
||||
```
|
||||
|
||||
```bash
|
||||
# ── 工作流 11b: 创建带条件的筛选视图(一步完成) ──
|
||||
|
||||
# 创建筛选视图时直接指定筛选条件
|
||||
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--name "高销售额视图" --range "A1:E100" \
|
||||
--criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50000"}]}]' \
|
||||
-f json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `filter-view list` | `filterViews` 筛选视图列表(含 `id`、`name`、`range`) | 获取 filterViewId 用于 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria |
|
||||
| `filter-view info` | `id`、`name`、`range`、`criteria` | 查看单个视图完整配置,确认条件是否生效 |
|
||||
| `filter-view create` | `id` 筛选视图 ID、`name`、`range` | 用于后续 update / delete / update-criteria / delete-criteria 的 --filter-view-id |
|
||||
| `filter-view update` | `id`、`name`、`range`、`criteria` | 确认更新结果 |
|
||||
| `filter-view delete` | `id` 被删除的筛选视图 ID | 确认删除完成 |
|
||||
| `filter-view update-criteria` | `id` 筛选视图 ID | 确认条件设置完成 |
|
||||
| `filter-view delete-criteria` | `id` 筛选视图 ID | 确认条件清除完成 |
|
||||
| `filter-view list-criteria` | 所有列条件(按列偏移量为 key 的对象) | 了解当前视图已设置哪些列的条件 |
|
||||
| `filter-view get-criteria` | 指定列的条件详情(`filterType`、`conditions` 等) | 查看某列的具体筛选规则 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
|
||||
- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id`
|
||||
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤
|
||||
- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}`
|
||||
- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示
|
||||
- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置
|
||||
- `filter-view update` 的 `--name`、`--range`、`--criteria` 至少需要传入一个,未指定的字段保持不变
|
||||
- `filter-view update` 的 `--criteria` 中指定列的条件会被替换,未指定的列保持不变
|
||||
- `filter-view delete` 删除后该视图及其所有筛选条件将被永久移除,不可恢复
|
||||
- `filter-view delete` 不影响全局筛选或其他筛选视图
|
||||
- `filter-view update-criteria` 的 `--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列
|
||||
- `filter-view update-criteria` 设置条件后立即在该筛选视图中生效,仅影响当前视图,不影响全局筛选或其他筛选视图
|
||||
- `filter-view update-criteria` 的 `--filter-criteria` 中 `conditions` 最多 2 个条件,多条件之间通过 `conditionOperator` 指定逻辑关系(`and` 或 `or`)
|
||||
- `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete`
|
||||
- `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错
|
||||
- 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作
|
||||
@@ -0,0 +1,181 @@
|
||||
# 全局筛选 (filter)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 筛选视图
|
||||
|
||||
用户说"筛选/过滤/只看某些值/只显示满足条件的行/筛选数据/创建筛选/删除筛选/设置筛选条件/清除筛选/排序":
|
||||
- 查看当前筛选 → `filter get`
|
||||
- 创建筛选 → `filter create`
|
||||
- 删除筛选 → `filter delete`
|
||||
- 批量设置多列条件 → `filter update`
|
||||
- 清除某一列条件 → `filter clear-criteria`
|
||||
- 按列排序 → `filter sort`
|
||||
- **区分全局筛选与筛选视图**:如果用户说"筛选视图"则走 `filter-view` 系列;如果只说"筛选/过滤/只看"则默认走全局 `filter` 系列
|
||||
- **禁止替代方案**:当用户要求"筛选/只看/仅保留某些行"时,必须通过 `filter create` / `filter update` 创建真实的筛选器。禁止用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替——这些做法会让原数据丢失或不可恢复
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 获取筛选信息
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter get [flags]
|
||||
Example:
|
||||
dws sheet filter get --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
dws sheet filter get --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --sheet-id "Sheet1"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
```
|
||||
|
||||
获取指定工作表的全局筛选信息,返回筛选范围和各列的筛选条件详情。
|
||||
- **用途**:查看当前工作表上是否存在全局筛选及其配置。
|
||||
- **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。
|
||||
- **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。
|
||||
- **返回**:`range`(筛选范围,A1 表示法)和 `columnFilterCriteria`(各列条件,key 为列偏移量)。如果未设置筛选,返回筛选信息为空。
|
||||
|
||||
### 创建筛选
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter create [flags]
|
||||
Example:
|
||||
# 创建筛选框架(不设条件)
|
||||
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100"
|
||||
|
||||
# 创建筛选并同时设置条件(按值筛选)
|
||||
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100" --criteria '[{"column":1,"filterType":"values","visibleValues":["北京","上海"]}]'
|
||||
|
||||
# 创建筛选并设置条件筛选
|
||||
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100" --criteria '[{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 筛选范围,A1 表示法,须包含表头行 (必填)
|
||||
--criteria string 筛选条件 JSON 数组 (可选)
|
||||
```
|
||||
|
||||
在工作表中创建全局筛选。
|
||||
- **用途**:为工作表建立筛选器,使数据可按条件过滤展示。
|
||||
- **约束**:每个工作表只能有一个全局筛选,已存在时会报错。应先 `filter get` 确认不存在后再创建。
|
||||
- **range 规范**:必须包含表头行(如 `A1:E100`),不能只包含数据行。
|
||||
- **criteria 格式**:JSON 数组,每个元素含 `column`(列偏移量,从 0 开始)和筛选条件字段。不传则仅创建空筛选框架,后续可通过 `filter update` 设置条件。
|
||||
|
||||
### 删除筛选
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter delete [flags]
|
||||
Example:
|
||||
dws sheet filter delete --node <NODE_ID> --sheet-id <SHEET_ID> --yes
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
```
|
||||
|
||||
删除工作表的全局筛选。
|
||||
- **用途**:移除筛选器,所有被隐藏的行将重新显示。
|
||||
- **不可逆**:删除后所有筛选条件丢失,需重新创建。
|
||||
- **前置**:工作表没有筛选时调用会报错,应先 `filter get` 确认存在。
|
||||
|
||||
### 批量更新筛选条件
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter update [flags]
|
||||
Example:
|
||||
# 同时设置多列的筛选条件
|
||||
dws sheet filter update --node <NODE_ID> --sheet-id <SHEET_ID> --criteria '[{"column":0,"filterType":"values","visibleValues":["已完成","进行中"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50"}]}]'
|
||||
|
||||
# 按颜色筛选
|
||||
dws sheet filter update --node <NODE_ID> --sheet-id <SHEET_ID> --criteria '[{"column":1,"filterType":"color","backgroundColor":"#FF0000"}]'
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--criteria string 筛选条件 JSON 数组 (必填)
|
||||
```
|
||||
|
||||
批量更新筛选条件,可同时设置多列的筛选条件。
|
||||
- **用途**:一次性设置或替换多列的筛选条件。
|
||||
- **前置**:工作表必须已创建筛选(通过 `filter create`)。
|
||||
- **覆盖式**:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置。
|
||||
- **criteria 格式**:JSON 数组,支持三种 `filterType`:
|
||||
- `values`:按值筛选,指定 `visibleValues` 数组
|
||||
- `condition`:按条件筛选,指定 `conditions` 数组(最多 2 个)和可选的 `conditionOperator`(`and`/`or`)
|
||||
- `color`:按颜色筛选,指定 `backgroundColor` 或 `fontColor`(二选一)
|
||||
|
||||
### 清除单列筛选条件
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter clear-criteria [flags]
|
||||
Example:
|
||||
# 清除第 2 列(B 列)的筛选条件
|
||||
dws sheet filter clear-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --column 1
|
||||
|
||||
# 清除第 1 列(A 列)的筛选条件
|
||||
dws sheet filter clear-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --column 0
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--column number 列偏移量,从 0 开始 (必填)
|
||||
```
|
||||
|
||||
清除筛选中某一列的筛选条件。
|
||||
- **用途**:移除某列的筛选条件,该列不再参与筛选计算。
|
||||
- **区分**:仅清除指定列的条件,不删除整个筛选。如需删除整个筛选,使用 `filter delete`。
|
||||
- **幂等**:指定列没有设置筛选条件时调用不会报错。
|
||||
|
||||
### 筛选排序
|
||||
```
|
||||
Usage:
|
||||
dws sheet filter sort [flags]
|
||||
Example:
|
||||
# 按第 1 列(A 列)升序排序
|
||||
dws sheet filter sort --node <NODE_ID> --sheet-id <SHEET_ID> --column 0 --ascending
|
||||
|
||||
# 按第 3 列(C 列)降序排序
|
||||
dws sheet filter sort --node <NODE_ID> --sheet-id <SHEET_ID> --column 2 --ascending=false
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--column number 排序列偏移量,从 0 开始 (必填)
|
||||
--ascending 是否升序,默认 true (可选)
|
||||
```
|
||||
|
||||
对筛选范围内的数据按指定列排序。
|
||||
- **用途**:对数据行按某一列的值进行升序或降序排列。
|
||||
- **前置**:工作表必须已创建筛选(通过 `filter create`)。
|
||||
- **注意**:排序会实际改变工作表中数据行的物理顺序,不可撤销。
|
||||
- **column**:列偏移量从 0 开始,相对于筛选范围首列。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `filter get` | `range`(筛选范围)、`columnFilterCriteria`(各列条件) | 查看当前筛选配置,确认筛选是否存在 |
|
||||
| `filter create` | 筛选创建成功的确认 | 确认筛选已建立,后续可通过 `filter update` 设置条件 |
|
||||
| `filter delete` | 删除成功的确认 | 确认筛选已删除 |
|
||||
| `filter update` | 更新成功的确认 | 确认条件已设置 |
|
||||
| `filter clear-criteria` | 清除成功的确认 | 确认指定列的条件已清除 |
|
||||
| `filter sort` | 排序成功的确认 | 确认排序已完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
|
||||
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)和 `columnFilterCriteria`(各列条件)。无筛选时返回空
|
||||
- `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错
|
||||
- `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件
|
||||
- `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复
|
||||
- `filter delete` 工作表没有筛选时调用会报错,应先 `filter get` 确认存在
|
||||
- `filter update` 是覆盖式:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置再 patch
|
||||
- `filter update` 前置:工作表必须已创建筛选
|
||||
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
|
||||
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
|
||||
- ★ **筛选操作规范**(参照飞书 core-operations):
|
||||
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
|
||||
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
|
||||
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
|
||||
- 筛选条件的列索引(`column`)必须与实际数据列精确对应,不要凭猜测填写
|
||||
- 筛选不支持正则表达式,传入正则会当成普通文本处理
|
||||
@@ -0,0 +1,239 @@
|
||||
# 媒体上传与图片 (media & image)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 媒体上传
|
||||
|
||||
用户说"上传附件/传文件到表格/上传文件到表格/上传到表格":
|
||||
- 上传附件 → `media-upload`(需表格 ID 或 URL + 本地文件路径)
|
||||
- 用户指定了上传后的名称 → `media-upload --name "自定义名称"`
|
||||
- `media-upload` 的 `--name` 参数用于指定附件在表格中显示的名称(不改变本地文件名);不传时默认使用本地文件名
|
||||
|
||||
用户说"写入图片/插入图片/加图片/放图片到单元格/嵌入图片到表格":
|
||||
- 写入图片 → `write-image`(需表格 ID + 工作表 ID + 单元格范围 + 本地图片路径)
|
||||
- 禁止使用 `range update` 写入图片,因为 `update_range` 的 MCP 工具不支持图片类型参数,调用必定失败。必须使用 `write-image` 命令
|
||||
- 用户指定了图片尺寸 → `write-image --width N --height M`
|
||||
|
||||
### 浮动图片
|
||||
|
||||
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
|
||||
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
|
||||
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
|
||||
|
||||
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
|
||||
- 列出所有浮动图片 → `list-float-images`
|
||||
- 查看某个浮动图片详情 → `get-float-image`
|
||||
|
||||
用户说"移动浮动图片/调整浮动图片大小/修改浮动图片/更新浮动图片":
|
||||
- 更新浮动图片属性 → `update-float-image`(可更新锚点位置、尺寸、偏移量、图片资源路径)
|
||||
|
||||
用户说"删除浮动图片/移除浮动图片":
|
||||
- 删除浮动图片 → `delete-float-image`
|
||||
|
||||
关键区分:`write-image`(单元格内嵌图片,占据单元格内容)vs `create-float-image`(浮动图片,悬浮于单元格之上,不占内容)
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 上传附件到表格
|
||||
```
|
||||
Usage:
|
||||
dws sheet media-upload [flags]
|
||||
Example:
|
||||
dws sheet media-upload --node <NODE_ID> --file ./report.pdf
|
||||
dws sheet media-upload --node <NODE_ID> --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream
|
||||
Flags:
|
||||
--node string 目标表格文档的标识,支持传入 URL 或 ID (必填)
|
||||
--file string 本地文件路径 (必填)
|
||||
--name string 附件显示名称 (默认使用文件名)
|
||||
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
|
||||
```
|
||||
|
||||
### 上传图片并写入表格单元格
|
||||
```
|
||||
Usage:
|
||||
dws sheet write-image [flags]
|
||||
Example:
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./logo.png --width 200 --height 100
|
||||
Flags:
|
||||
--node string 目标表格文档的标识,支持传入 URL 或 ID (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格区域地址,如 A1:A1 (必填)
|
||||
--file string 本地图片文件路径 (必填)
|
||||
--name string 图片显示名称 (默认使用文件名)
|
||||
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
|
||||
--width int 图片显示宽度 (可选)
|
||||
--height int 图片显示高度 (可选)
|
||||
```
|
||||
|
||||
### 创建浮动图片
|
||||
```
|
||||
Usage:
|
||||
dws sheet create-float-image [flags]
|
||||
Example:
|
||||
# 先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node <NODE_ID> --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
# 再创建浮动图片
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
|
||||
|
||||
# 带偏移量
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
|
||||
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
|
||||
--width int 图片宽度,像素,正整数 (必填)
|
||||
--height int 图片高度,像素,正整数 (必填)
|
||||
--offset-x int 水平偏移量,像素 (默认 0)
|
||||
--offset-y int 垂直偏移量,像素 (默认 0)
|
||||
```
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
|
||||
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `--width` / `--height` 为必填,单位像素,必须为正整数
|
||||
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
|
||||
|
||||
### 获取浮动图片详情
|
||||
```
|
||||
Usage:
|
||||
dws sheet get-float-image [flags]
|
||||
Example:
|
||||
dws sheet get-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
```
|
||||
|
||||
获取单个浮动图片的详细信息,包括 ID、图片资源路径、锚点位置、尺寸和偏移量。
|
||||
`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
### 列出工作表所有浮动图片
|
||||
```
|
||||
Usage:
|
||||
dws sheet list-float-images [flags]
|
||||
Example:
|
||||
dws sheet list-float-images --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
```
|
||||
|
||||
列出指定工作表中所有浮动图片,返回 `floatImages` 数组和 `totalCount`。
|
||||
|
||||
### 更新浮动图片属性
|
||||
```
|
||||
Usage:
|
||||
dws sheet update-float-image [flags]
|
||||
Example:
|
||||
# 移动浮动图片到新位置
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --range C5
|
||||
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
|
||||
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--src "/core/api/resources/img/xxxx..."
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
|
||||
--range string 新的锚点单元格,A1 表示法
|
||||
--width int 新的图片宽度,像素
|
||||
--height int 新的图片高度,像素
|
||||
--offset-x int 新的水平偏移量,像素
|
||||
--offset-y int 新的垂直偏移量,像素
|
||||
```
|
||||
|
||||
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
|
||||
`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
### 删除浮动图片
|
||||
```
|
||||
Usage:
|
||||
dws sheet delete-float-image [flags]
|
||||
Example:
|
||||
dws sheet delete-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
```
|
||||
|
||||
删除指定的浮动图片,操作不可恢复。`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 9: 上传附件到表格 ──
|
||||
|
||||
# 1. 基本用法: 上传本地文件到表格
|
||||
dws sheet media-upload --node <NODE_ID> --file ./report.pdf -f json
|
||||
|
||||
# 2. 自定义附件显示名称 (--name 指定上传后在表格中显示的名称)
|
||||
dws sheet media-upload --node <NODE_ID> --file ./data.csv --name "销售数据.csv" -f json
|
||||
|
||||
# 3. 指定 MIME 类型 (文件扩展名无法推断时)
|
||||
dws sheet media-upload --node <NODE_ID> --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream -f json
|
||||
|
||||
# 4. 完整流程: 创建表格 → 上传附件
|
||||
dws sheet create --name "项目资料" -f json
|
||||
# 提取 nodeId 后:
|
||||
dws sheet media-upload --node <NODE_ID> --file ./design.pdf -f json
|
||||
dws sheet media-upload --node <NODE_ID> --file ./timeline.xlsx --name "项目时间线.xlsx" -f json
|
||||
|
||||
# ── 工作流 10: 写入图片到表格单元格 ──
|
||||
|
||||
# 1. 基本用法: 写入图片到指定单元格
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png -f json
|
||||
|
||||
# 2. 指定显示尺寸
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./logo.png --width 200 --height 100 -f json
|
||||
|
||||
# 3. 自定义图片名称
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range C3:C3 --file ./photo.jpg --name "产品图.jpg" -f json
|
||||
|
||||
# 4. 完整流程: 创建表格 → 写表头 → 写入图片
|
||||
dws sheet create --name "产品目录" -f json
|
||||
# 提取 nodeId 后:
|
||||
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A1:B1" --values '[["产品名称","产品图片"]]' -f json
|
||||
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A2:A2" --values '[["MacBook Pro"]]' -f json
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `media-upload` | `resourceId`、`resourceUrl` | 附件已上传到表格;`resourceUrl` 可用于 `create-float-image` 的 `--src` |
|
||||
| `write-image` | `resourceId` | 图片已写入指定单元格 |
|
||||
| `create-float-image` | `floatImage`(含 `id`、`src`、`range`、`width`、`height`、`offsetX`、`offsetY`) | `id` 用于后续 get / update / delete 的 `--float-image-id` |
|
||||
| `get-float-image` | `floatImage`(完整信息) | 查看单个浮动图片详情 |
|
||||
| `list-float-images` | `floatImages` 数组、`totalCount` | 获取所有浮动图片的 `id`,用于后续操作 |
|
||||
| `update-float-image` | `floatImage`(更新后的完整信息) | 确认更新结果 |
|
||||
| `delete-float-image` | `message` | 确认删除完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
|
||||
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
|
||||
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败
|
||||
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
|
||||
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
|
||||
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
|
||||
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
|
||||
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
|
||||
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
|
||||
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
|
||||
@@ -0,0 +1,153 @@
|
||||
# 区域操作
|
||||
|
||||
## 使用场景
|
||||
|
||||
用户说"清空/清除区域/擦除内容/清除格式":
|
||||
- 清除区域 → `range clear`
|
||||
- 仅清除值 → `range clear --type content`(默认)
|
||||
- 仅清除格式 → `range clear --type format`
|
||||
- 全部清除 → `range clear --type all`
|
||||
- 请勿用 `range update` 写入空字符串来模拟清空,`range clear` 更简洁且支持按类型清除
|
||||
|
||||
用户说"排序/给数据排序/按某列排序/升序/降序":
|
||||
- 区域排序 → `range sort`
|
||||
- **排序前必须先 `range read` 前 3-5 行**:读取排序范围的前几行(如范围是 A1:D100 则读 A1:D5),对比首行与后续行的模式来判断是否有表头:
|
||||
- 首行全文本 + 后续行含数字/日期 → 有表头,加 `--has-header`
|
||||
- 首行与后续行模式一致(都是数字或都是文本) → 无表头,不加
|
||||
- 首行值语义像列标题(如"姓名""金额""日期")且与后续行明显不同 → 有表头
|
||||
禁止不读就排——表头误排入数据是不可撤销的破坏性操作
|
||||
- 请勿用 `range read` 读取数据后客户端排序再 `range update` 写回,`range sort` 是服务端原子操作
|
||||
|
||||
用户说"自动填充/填充序列/向下填充/拖拽填充/序列递增":
|
||||
- 自动填充 → `range fill`
|
||||
- 请勿用 `range read` 读取源数据后手动计算规律再 `range update` 写入,`range fill` 支持服务端智能填充
|
||||
|
||||
用户说"复制区域/把这块数据复制到/复制到另一个工作表":
|
||||
- 复制区域 → `range copy-to`
|
||||
- 跨工作表 → `range copy-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
|
||||
- 请勿用 `range read` + `range update` 读取再写入来模拟复制,`range copy-to` 是原子操作,保留公式引用调整
|
||||
|
||||
用户说"移动区域/把数据移到/剪切粘贴/移到另一个工作表":
|
||||
- 移动区域 → `range move-to`
|
||||
- 跨工作表 → `range move-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
|
||||
- 请勿用 `range read` + `range update` + `range clear` 读取-写入-清空来模拟移动,`range move-to` 是原子操作
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 清除区域
|
||||
```
|
||||
Usage:
|
||||
dws sheet range clear [flags]
|
||||
Example:
|
||||
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3"
|
||||
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --type format
|
||||
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --type all
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 清除范围,A1 表示法 (必填)
|
||||
--type string 清除类型: content(仅值,默认) / format(仅格式) / all(全部)
|
||||
```
|
||||
|
||||
### 区域排序
|
||||
```
|
||||
Usage:
|
||||
dws sheet range sort [flags]
|
||||
Example:
|
||||
dws sheet range sort --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" \
|
||||
--sort-keys '[{"column":"A","ascending":true}]'
|
||||
dws sheet range sort --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" \
|
||||
--sort-keys '[{"column":"A","ascending":true},{"column":"C","ascending":false}]' --has-header
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 排序范围,A1 表示法 (必填)
|
||||
--sort-keys string 排序规则 JSON 数组 (必填)
|
||||
--has-header 首行是否为表头(不参与排序)
|
||||
```
|
||||
|
||||
`--sort-keys` 格式:`[{"column":"A","ascending":true}]`,`column` 使用字母列名(如 "A"、"B"、"AA")。多级排序按数组顺序优先级递减。
|
||||
|
||||
### 区域自动填充
|
||||
```
|
||||
Usage:
|
||||
dws sheet range fill [flags]
|
||||
Example:
|
||||
dws sheet range fill --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:A5" --target-range "A6:A20"
|
||||
dws sheet range fill --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:A5" --target-range "A6:A20" --fill-type copy
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--source-range string 源数据范围,A1 表示法 (必填)
|
||||
--target-range string 目标填充范围,A1 表示法 (必填)
|
||||
--fill-type string 填充类型: series(序列,默认) / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值)
|
||||
```
|
||||
|
||||
目标范围须与源范围在行或列维度对齐(不支持对角填充)。
|
||||
|
||||
### 复制区域
|
||||
```
|
||||
Usage:
|
||||
dws sheet range copy-to [flags]
|
||||
Example:
|
||||
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:C5" --target-range "D1"
|
||||
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:C5" --target-range "A1" --target-sheet-id "Sheet2"
|
||||
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:C5" --target-range "D1" --paste-type values
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 源工作表 ID 或名称 (必填)
|
||||
--source-range string 源范围,A1 表示法 (必填)
|
||||
--target-range string 目标位置,A1 表示法 (必填)
|
||||
--target-sheet-id string 目标工作表 ID 或名称(可选,不传则复制到同一工作表)
|
||||
--paste-type string 粘贴类型: values(仅值) / formulas(仅公式) / formats(仅格式) / all(全部,默认)
|
||||
```
|
||||
|
||||
支持跨工作表复制,两种方式指定目标工作表:
|
||||
- `--target-sheet-id "Sheet2"` 显式指定
|
||||
- `--target-range "Sheet2!A1"` 在目标范围中携带工作表前缀
|
||||
|
||||
源和目标范围不能重叠(同表时)。
|
||||
|
||||
### 移动区域
|
||||
```
|
||||
Usage:
|
||||
dws sheet range move-to [flags]
|
||||
Example:
|
||||
dws sheet range move-to --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:C5" --target-range "D1"
|
||||
dws sheet range move-to --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--source-range "A1:C5" --target-range "A1" --target-sheet-id "Sheet2"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 源工作表 ID 或名称 (必填)
|
||||
--source-range string 源范围,A1 表示法 (必填)
|
||||
--target-range string 目标位置,A1 表示法 (必填)
|
||||
--target-sheet-id string 目标工作表 ID 或名称(可选,不传则移动到同一工作表)
|
||||
```
|
||||
|
||||
支持跨工作表移动,两种方式指定目标工作表:
|
||||
- `--target-sheet-id "Sheet2"` 显式指定
|
||||
- `--target-range "Sheet2!A1"` 在目标范围中携带工作表前缀
|
||||
|
||||
源和目标范围不能重叠(同表时)。移动后源区域将被清空。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | 工作表的 `sheetId` | range clear / range sort / range fill / range copy-to / range move-to 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
|
||||
- ★ **清空区域用 `range clear` 不用 `range update`**:`range clear` 支持按类型(值/格式/全部)清除,比手动构造全空数组更简洁可靠
|
||||
- ★ **复制区域用 `range copy-to` 不用 `range read` + `range update`**:原子操作,保留公式引用自动调整,支持跨工作表
|
||||
- ★ **移动区域用 `range move-to` 不用 `range read` + `range update` + `range clear`**:原子操作,源区域自动清空,支持跨工作表
|
||||
- ★ **排序用 `range sort` 不用 `range read` + 客户端排序 + `range update`**:服务端原子操作,支持多级排序
|
||||
- ★ **排序前必须 `range read` 前几行判断表头**:读取排序范围前 3-5 行,对比首行与后续行的数据模式(类型、语义)来判断是否有表头。禁止不读就排,表头被排入数据不可撤销
|
||||
- ★ **填充用 `range fill` 不用 `range read` + 手动计算 + `range update`**:服务端智能填充,支持序列递增、公式扩展等
|
||||
@@ -0,0 +1,204 @@
|
||||
# 数据读取
|
||||
|
||||
## 使用场景
|
||||
|
||||
用户说"读数据/看表格内容":
|
||||
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
|
||||
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
|
||||
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
|
||||
|
||||
## 命令选择
|
||||
|
||||
| 读取目的 | 推荐命令 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
|
||||
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
|
||||
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
|
||||
| 查看单元格级超链接 | `range read` | 返回 per-cell 结构,含 hyperlink;富文本片段链接仍在 richText 内 |
|
||||
| 查看公式文本 | `range read --value-render-option formula` | value 返回公式 |
|
||||
| 获取原始值(数字/布尔而非格式化字符串) | `range read --value-render-option raw_value` | value 返回原始类型 |
|
||||
| 查看合并单元格范围 | `sheet info` | 返回 `mergedRanges`,这是工作表结构信息,不属于单元格值读取 |
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 以 CSV 格式读取工作表数据(推荐)
|
||||
```
|
||||
Usage:
|
||||
dws sheet csv-get [flags]
|
||||
Example:
|
||||
dws sheet csv-get --node <NODE_ID>
|
||||
dws sheet csv-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10"
|
||||
dws sheet csv-get --node <NODE_ID> --range "A1:Z500" --value-render-option raw_value
|
||||
dws sheet csv-get --node <NODE_ID> --range "A1:D10" --max-chars 50000
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表)
|
||||
--range string 读取范围,A1 表示法 (不传则读取全部非空数据)
|
||||
--value-render-option string 取值模式: formatted_value(默认) | raw_value | formula
|
||||
--max-chars int CSV 最大字符数 (默认 200000,超出截断)
|
||||
```
|
||||
|
||||
**返回字段说明**:
|
||||
- `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算
|
||||
- `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号
|
||||
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
|
||||
- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取
|
||||
|
||||
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
|
||||
|
||||
**取值模式说明**:
|
||||
| 模式 | 返回内容 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `formatted_value` | 格式化展示值(如 ¥1,000.00、2025-06-01) | 只看数据 |
|
||||
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
|
||||
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
|
||||
|
||||
**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批:
|
||||
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
|
||||
- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` ……
|
||||
- 单次建议 ≤5000 单元格
|
||||
|
||||
### 读取工作表数据(per-cell 结构化信息)
|
||||
```
|
||||
Usage:
|
||||
dws sheet range read [flags] # 别名: dws sheet range get
|
||||
Example:
|
||||
dws sheet range read --node <NODE_ID>
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
dws sheet range read --node <NODE_ID> --sheet-id "Sheet1" --range "A1:D10"
|
||||
dws sheet range read --node <NODE_ID> --range "Sheet1!A1:D10"
|
||||
dws sheet range read --node <NODE_ID> --value-render-option raw_value
|
||||
dws sheet range read --node <NODE_ID> --value-render-option formula
|
||||
|
||||
# 使用 get 别名,与 read 等价
|
||||
dws sheet range get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表)
|
||||
--range string 读取范围,A1 表示法 (如 A1:D10,不传则读取全部数据)
|
||||
--value-render-option string 取值模式: formatted_value(默认) | raw_value | formula
|
||||
```
|
||||
|
||||
**返回字段说明**:
|
||||
- `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下:
|
||||
|
||||
| 字段 | 类型 | 是否必有 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `value` | string / number / boolean / null | 始终存在 | 单元格值。`formatted_value` 模式为 string;`raw_value` 模式为原始类型(number/boolean/string/null);`formula` 模式为公式字符串或回退原始值 |
|
||||
| `dataValidation` | object | 仅有数据验证时出现,无则省略 | 数据验证配置,见下表 |
|
||||
| `hyperlink` | object | 仅有单元格级超链接时出现,无则省略 | 整格超链接,结构为 `{type, link, text?}`,见下表 |
|
||||
| `richText` | object | 仅富文本单元格出现 | 富文本结构(含超链接、附件、图片、样式片段等),普通纯文本不含此字段 |
|
||||
| `cellStyles` | object | 仅有显式设置的样式时出现;MCP 序列化层也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 |
|
||||
|
||||
`range read` / `range get` 不返回合并单元格结构。要看合并单元格,请先或另行调用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json`,使用其中的 `mergedRanges`。
|
||||
|
||||
**dataValidation 结构**:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 |
|
||||
| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 |
|
||||
| `checkbox` | `checked: boolean` | 当前勾选状态 |
|
||||
|
||||
**hyperlink 结构**:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `path` | `link` + 可选 `text` | 外部 URL 链接 |
|
||||
| `sheet` | `link` + 可选 `text` | 工作表链接,`link` 为工作表 ID 或名称 |
|
||||
| `range` | `link` + 可选 `text` | 单元格范围链接,`link` 为 A1 表示法,如 `Sheet1!A4` |
|
||||
|
||||
**richText 结构**:
|
||||
|
||||
`richText` 表示单元格内的富文本片段,常见结构为 `{type:"richText", texts:[...]}`。`texts` 数组内每个子项代表一个片段:
|
||||
|
||||
| 子项 type | 常见字段 | 说明 |
|
||||
|-----------|----------|------|
|
||||
| `text` | `text` / `style` | 普通文本片段;`style` 是片段级样式 |
|
||||
| `link` | `text` / `link` / `subType` / `style` | 富文本片段链接。`subType` 不存在时按 `path` 理解;`path` 表示外部 URL,`sheet` 表示工作表链接,`range` 表示单元格范围链接 |
|
||||
| `attachment` | `text` / `resourceId` / `mimeType` / `size` | 附件片段 |
|
||||
| `image` | `resourceId` / `resourceUrl` / `width` / `height` | 图片片段 |
|
||||
|
||||
`richText.texts[].link.subType` 与 cell-level `hyperlink.type` 含义一致,但作用范围不同:`hyperlink` 是整个单元格可点击,richText `link` 只作用于该文本片段。读取到 `subType:"sheet"` 时,`link` 通常是真实工作表名称;读取到 `subType:"range"` 时,`link` 通常是 A1 范围(如 `Sheet2!A1:B20`)。不要把 richText 片段链接误当成整格 `hyperlink`。
|
||||
|
||||
**cellStyles 字段说明**(仅关注显式设置过的非 null 属性;未设置属性不存在或为 null,应忽略):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `fontWeight` | string | `bold` / `normal` |
|
||||
| `fontColor` | string | 字体颜色,`#RRGGBB` |
|
||||
| `fontSize` | number | 字号 |
|
||||
| `fontStyle` | string | `italic` / `normal` |
|
||||
| `backgroundColor` | string | 背景色,`#RRGGBB` |
|
||||
| `horizontalAlignment` | string | `left` / `center` / `right` / `general` |
|
||||
| `verticalAlignment` | string | `top` / `middle` / `bottom` |
|
||||
| `wordWrap` | string | `overflow` / `clip` / `autoWrap` |
|
||||
| `numberFormat` | string | 数字格式代码,如 `@`、`#,##0.00`、`yyyy/m/d`;`@` 表示文本 |
|
||||
| `textUnderline` | boolean | 下划线 |
|
||||
| `textLineThrough` | boolean | 删除线 |
|
||||
|
||||
**返回示例**:
|
||||
```json
|
||||
{
|
||||
"cells": [
|
||||
[
|
||||
{"value": "姓名", "cellStyles": {"fontWeight": "bold", "backgroundColor": "#FFF2CC"}},
|
||||
{"value": "状态", "cellStyles": {"fontWeight": "bold"}, "dataValidation": {"type": "dropdown", "options": [{"value": "进行中"}, {"value": "已完成", "color": "#52C41A"}], "enableMultiSelect": false}}
|
||||
],
|
||||
[
|
||||
{"value": "张三"},
|
||||
{"value": "钉钉", "hyperlink": {"type": "path", "link": "https://dingtalk.com", "text": "钉钉"}}
|
||||
]
|
||||
],
|
||||
"message": "Successfully retrieved cell data.",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:MCP 平台序列化会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。
|
||||
|
||||
**取值模式说明**:
|
||||
| 模式 | value 返回内容 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `formatted_value` | 格式化展示值(如 ¥1,000.00、2025-06-01) | 只看数据(默认) |
|
||||
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
|
||||
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
|
||||
|
||||
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略:
|
||||
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
|
||||
- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` ……
|
||||
- 避免不传 `--range` 直接读取整个大工作表
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流: 读取已有表格数据 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等)
|
||||
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 3. 读取全部数据
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 4. 读取指定区域
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | 工作表的 `sheetId` | info / range read 的 --sheet-id |
|
||||
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、分页读取边界、识别合并单元格结构 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
|
||||
- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据
|
||||
- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`)
|
||||
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
|
||||
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表
|
||||
- ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确
|
||||
@@ -0,0 +1,118 @@
|
||||
# 搜索与替换
|
||||
|
||||
## 使用场景
|
||||
|
||||
用户说"搜索/查找/找单元格/搜内容/精确搜索/精确匹配/完全匹配/全字匹配":
|
||||
- 搜索单元格 → `find`
|
||||
- 精确匹配(只匹配完全等于的,不匹配包含的) → `find --match-entire-cell`
|
||||
- 正则搜索 → `find --use-regexp`
|
||||
- 搜索公式 → `find --match-formula`
|
||||
- 不要用 `range read` 读取全量数据后在客户端过滤来替代 `find`,必须使用 `find` 命令的服务端搜索能力
|
||||
|
||||
用户说"替换/查找替换/全局替换/批量替换/把A替换成B/把所有的X改成Y":
|
||||
- 查找替换 → `replace`
|
||||
- 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell`
|
||||
- 正则替换 → `replace --use-regexp`
|
||||
- 删除匹配内容 → `replace --replacement ""`
|
||||
- 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 在工作表中搜索单元格内容
|
||||
```
|
||||
Usage:
|
||||
dws sheet find [flags]
|
||||
Example:
|
||||
# 基本搜索
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "销售额"
|
||||
|
||||
# 在指定范围内搜索
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "合计" --range "A1:D100"
|
||||
|
||||
# 正则表达式搜索(不区分大小写)
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "^total" --use-regexp --match-case=false
|
||||
|
||||
# 精确匹配整个单元格内容
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "完成" --match-entire-cell
|
||||
|
||||
# 搜索公式文本
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "SUM" --match-formula
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--find string 搜索文本 (必填)
|
||||
--range string 搜索范围,A1 表示法 (如 A1:D10)
|
||||
--match-case 区分大小写 (默认 true)
|
||||
--match-entire-cell 精确匹配整个单元格内容
|
||||
--use-regexp 启用正则表达式搜索
|
||||
--match-formula 搜索公式文本而非显示值
|
||||
--include-hidden 包含隐藏单元格
|
||||
```
|
||||
|
||||
### 全局查找替换
|
||||
```
|
||||
Usage:
|
||||
dws sheet replace [flags]
|
||||
Example:
|
||||
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "旧文本" --replacement "新文本"
|
||||
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "待处理" --replacement "已完成" --match-entire-cell
|
||||
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "\\d{4}" --replacement "****" --use-regexp
|
||||
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "旧" --replacement "新" --range "A1:D100"
|
||||
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "临时" --replacement ""
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--find string 查找文本 (必填)
|
||||
--replacement string 替换文本 (必填,可为空字符串表示删除)
|
||||
--range string 替换范围,A1 表示法 (如 A1:D100)
|
||||
--match-case 区分大小写 (默认 false)
|
||||
--match-entire-cell 完整单元格匹配
|
||||
--use-regexp 启用正则表达式匹配
|
||||
--include-hidden 包含隐藏行/列
|
||||
```
|
||||
|
||||
返回被替换的单元格数量。`--replacement` 可以为空字符串,表示删除匹配内容。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流: 搜索表格数据 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 基本搜索 — 在指定工作表中查找文本
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "销售额" --format json
|
||||
|
||||
# 3. 在指定范围内搜索
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "合计" --range "A1:D100" --format json
|
||||
|
||||
# 4. 正则搜索(不区分大小写)
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "^total" --use-regexp --match-case=false --format json
|
||||
|
||||
# 5. 精确匹配整个单元格
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "完成" --match-entire-cell --format json
|
||||
|
||||
# 6. 搜索公式文本
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "SUM" --match-formula --format json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | 工作表的 `sheetId` | find / replace 的 --sheet-id |
|
||||
| `find` | `matchedCells` 中的 `a1Notation` | 定位目标单元格,用于 range read / range update |
|
||||
| `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
|
||||
- ★ **搜索用 `find` 不用 `range read`**:`find` 是服务端搜索,禁止用 `range read` 全量读取后客户端过滤
|
||||
- ★ **替换用 `replace` 不用 `range update`**:`replace` 是服务端原子操作,返回替换计数
|
||||
- `find` 返回匹配单元格的地址(A1 表示法)和值,无匹配时返回空数组
|
||||
- `find` 的 `--match-entire-cell` 用于精确匹配:只返回单元格内容完全等于搜索文本的结果,不会匹配包含该文本的单元格(例如搜索"苹果"时,只匹配"苹果",不匹配"苹果手机""苹果汁"等)。用户说"精确搜索/完全匹配/只搜等于XX的"时必须使用此参数
|
||||
- `find` 的 `--match-case` 默认为 true(区分大小写),设为 false 可忽略大小写
|
||||
- `find` 的 `--use-regexp` 启用后,`--find` 参数作为正则表达式处理
|
||||
- `replace` 的 `--find` 不能为空字符串,`--replace` 可以为空字符串(表示删除匹配内容)
|
||||
- `replace` 的 `--match-case` 默认为 false(不区分大小写),与 `find` 的默认行为不同
|
||||
@@ -0,0 +1,274 @@
|
||||
# 单元格格式与合并 (style & format)
|
||||
|
||||
## 三种样式设置方式
|
||||
|
||||
钉钉表格支持三种样式设置方式,适用不同场景:
|
||||
|
||||
| 方式 | 命令 / 字段 | 适用场景 | 粒度 |
|
||||
|------|------------|---------|------|
|
||||
| **`set-style` / `batch-set-style`** | `dws sheet range set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等) | range 级别(2D 数组或全 range 统一值) |
|
||||
| **`cellStyles`**(`range update` 内) | `--values` 中每个 cell 的 `cellStyles` 字段 | 写值同时附带样式,少量 cell 一步到位 | per-cell 级别 |
|
||||
| **`style`**(richText 片段样式) | `--values` 中 richText 子项(`text`/`link`)的 `style` | 同一单元格内不同文字有不同字体样式 | 文本片段级别 |
|
||||
|
||||
选择建议:
|
||||
- 只设样式不改值 → `set-style` / `batch-set-style`
|
||||
- 写值 + 样式一步到位(少量 cell) → `range update` + `cellStyles`
|
||||
- 文本内部分段样式("重要"红色加粗,其余正常) → `range update` + `type:"richText"` 子项 `style`
|
||||
- 大面积统一样式 → `set-style`(单值刷 range)或 `batch-set-style`(多 range 批量)
|
||||
|
||||
注意:`set-style` / `batch-set-style` 和 `range update` 的 `cellStyles` 最终都作用于 cell-level 样式,效果相同。区别在于调用方式——前者是独立命令,后者嵌在写值调用中。`range read` 返回的 `cellStyles` 字段能读回所有显式设置过的 cell-level 样式,无论是通过哪种方式设置的。
|
||||
|
||||
`type:"text"` 顶层旧 `style` 字段不要作为新写法使用;整格样式用 `cellStyles`,分段样式才用 richText 子项 `style`。
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 单元格格式
|
||||
|
||||
用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号":
|
||||
- 仅设样式不改值 → `range set-style`
|
||||
- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(内部顺序循环调 `update_range`)
|
||||
- 写值同时附带样式 → `range update --values` 中使用 `cellStyles` 字段(参见 sheet-write-data.md)
|
||||
- 请勿用 `range update --values` 写空/重写来模拟纯样式变更
|
||||
|
||||
用户说"设置数字格式/改成百分比/用人民币显示/按日期显示/文本格式/保留几位小数":
|
||||
- 批量设置数字格式 → `range set-style --number-format <格式代码>`(如 `0%` / `"¥"#,##0.00` / `yyyy/m/d` / `@`)
|
||||
- 写值时顺带设置数字格式 → `range update` 中 `cellStyles.numberFormat`
|
||||
|
||||
用户说"合并单元格/合并/合并区域/按行合并/按列合并":
|
||||
- 合并所有单元格 → `merge-cells`(默认 mergeAll)
|
||||
- 按行合并 → `merge-cells --merge-type mergeRows`
|
||||
- 按列合并 → `merge-cells --merge-type mergeColumns`
|
||||
|
||||
用户说"取消合并/拆分单元格/还原合并":
|
||||
- 取消合并单元格 → `unmerge-cells`
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 设置单元格样式
|
||||
```
|
||||
Usage:
|
||||
dws sheet range set-style [flags]
|
||||
Example:
|
||||
# 给 A1:B3 打上黄底粗体居中
|
||||
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" \
|
||||
--bg-color "#FFF2CC" --font-weight bold --h-align center
|
||||
|
||||
# 给 C1:C5 逐单元格设置不同背景色
|
||||
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "C1:C5" \
|
||||
--bg-colors-json '[["#FF0000"],["#00FF00"],["#0000FF"],["#FFFF00"],["#FF00FF"]]'
|
||||
|
||||
# 整片 range 启用自动换行
|
||||
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E10" --word-wrap autoWrap
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标区域,如 A1:B3 (必填)
|
||||
--bg-color string 背景色(#RRGGBB),一键刷整个 range;与 --bg-colors-json 二选一
|
||||
--bg-colors-json string 背景色二维 JSON 数组,维度需与 --range 一致
|
||||
--font-size int 字号,一键刷整个 range;与 --font-sizes-json 二选一
|
||||
--font-sizes-json string 字号二维 JSON 数组
|
||||
--h-align string 水平对齐:left/center/right/general
|
||||
--h-aligns-json string 水平对齐二维 JSON 数组
|
||||
--v-align string 垂直对齐:top/middle/bottom
|
||||
--v-aligns-json string 垂直对齐二维 JSON 数组
|
||||
--font-color string 字体颜色(#RRGGBB)
|
||||
--font-colors-json string 字体颜色二维 JSON 数组
|
||||
--font-weight string 字体粗细:bold/normal
|
||||
--font-weights-json string 字体粗细二维 JSON 数组
|
||||
--word-wrap string 换行方式:overflow/clip/autoWrap(整个 range 共用)
|
||||
--number-format string 数字格式代码,如 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss
|
||||
```
|
||||
|
||||
**特性说明**:
|
||||
- 每个样式维度提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致)
|
||||
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000
|
||||
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center` 等
|
||||
|
||||
### 批量设置单元格样式
|
||||
```
|
||||
Usage:
|
||||
dws sheet range batch-set-style [flags]
|
||||
Example:
|
||||
dws sheet range batch-set-style --node <NODE_ID> --batch ./styles.json
|
||||
dws sheet range batch-set-style --node <NODE_ID> --batch ./styles.json --continue-on-error
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--batch string 批次配置 JSON 文件路径 (必填)
|
||||
--continue-on-error 遇到失败时继续执行后续条目(默认遇错即停)
|
||||
```
|
||||
|
||||
配置文件格式(JSON 数组,每个元素一条批次项):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sheetId": "Sheet1",
|
||||
"range": "A1:B3",
|
||||
"bgColor": "#FFF2CC",
|
||||
"fontSize": 12,
|
||||
"hAlign": "center",
|
||||
"vAlign": "middle",
|
||||
"fontColor": "#333333",
|
||||
"fontWeight": "bold",
|
||||
"wordWrap": "autoWrap",
|
||||
"numberFormat": "General"
|
||||
},
|
||||
{
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C1:C5",
|
||||
"bgColorsJson": "[[\"#FF0000\"],[\"#00FF00\"],[\"#0000FF\"],[\"#FFFF00\"],[\"#FF00FF\"]]"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**特性说明**:
|
||||
- CLI 侧顺序循环逐条调用 `update_range`(非服务端批量),运行时输出 `[N/M]` 进度
|
||||
- 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法
|
||||
- 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误
|
||||
|
||||
### 合并单元格
|
||||
```
|
||||
Usage:
|
||||
dws sheet merge-cells [flags]
|
||||
Example:
|
||||
# 合并所有单元格(默认)
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3"
|
||||
|
||||
# 按行合并
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeRows
|
||||
|
||||
# 按列合并
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeColumns
|
||||
|
||||
# 使用带工作表前缀的范围(忽略 --sheet-id)
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!A1:B3"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格区域地址,如 A1:B3 (必填)
|
||||
--merge-type string 合并方式: mergeAll(默认)/mergeRows/mergeColumns
|
||||
```
|
||||
|
||||
支持三种合并方式:
|
||||
- `mergeAll`(默认):合并所有单元格,将选定区域内的所有单元格合并成一个
|
||||
- `mergeRows`:按行合并,在选定区域内将同一行相邻的单元格合并
|
||||
- `mergeColumns`:按列合并,在选定区域内将同一列相邻的单元格合并
|
||||
|
||||
注意:合并时只保留左上角单元格的值,其他单元格的值会被丢弃。
|
||||
`--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时将优先使用前缀解析出的工作表,忽略 `--sheet-id`。
|
||||
合并完成后,可通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges` 验证合并结构。
|
||||
|
||||
### 取消合并单元格
|
||||
```
|
||||
Usage:
|
||||
dws sheet unmerge-cells [flags]
|
||||
Example:
|
||||
dws sheet unmerge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D5"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 取消合并的范围,A1 表示法 (必填)
|
||||
```
|
||||
|
||||
取消指定范围内所有合并的单元格,恢复为独立单元格。
|
||||
|
||||
## number-format 格式 code
|
||||
|
||||
适用范围:`number-format` 在 `range set-style` / `range batch-set-style` 中接受(CLI 对应 `--number-format`,batch 配置文件对应 `numberFormat`)。`range update` 没有 `--number-format` 参数,但可在写入值时通过每个 cell 的 `cellStyles.numberFormat` 设置同样的数字格式。
|
||||
|
||||
商品 ID、规格 ID、SKU、订单号、手机号、工号等数字形态标识符,使用文本格式 code:`@`。
|
||||
|
||||
常用格式:
|
||||
|
||||
| 格式类型 | 推荐 code | 展示示例 | 适用场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 常规 | `General` | `1234` / `普通文本` | 普通文本/数字展示 |
|
||||
| 文本 | `@` | `528545015680` | 商品 ID、规格 ID、SKU、订单号、手机号、工号 |
|
||||
| 整数 | `0` | `1235` | 数量、计数 |
|
||||
| 两位小数 | `0.00` | `1234.50` | 单价、评分 |
|
||||
| 整数千分位 | `#,##0` | `1,235` | 数量、金额整数 |
|
||||
| 千分位两位小数 | `#,##0.00` | `1,234.50` | 金额、单价 |
|
||||
| 百分比 | `0%` / `0.00%` | `85%` / `85.00%` | 转化率、占比 |
|
||||
| 日期 | `yyyy/m/d` | `2026/3/15` | 日期列 |
|
||||
| 日期时间 | `yyyy/m/d h:mm` | `2026/3/15 14:30` | 日期时间列 |
|
||||
| 时间 | `h:mm` / `h:mm:ss` | `14:30` / `14:30:05` | 时间列 |
|
||||
| 科学计数法 | `0.00E+00` / `##0.0E+0` | `1.23E+05` | 科学数据 |
|
||||
| 人民币 | `"¥"#,##0_);("¥"#,##0)` / `"¥"#,##0.00_);("¥"#,##0.00)` | `¥1,235` / `¥1,234.50` | 金额列 |
|
||||
| 美元 | `$#,##0_);($#,##0)` / `$#,##0.00_);($#,##0.00)` | `$1,235` / `$1,234.50` | 金额列 |
|
||||
|
||||
选择规则:没有特殊展示要求时,优先使用上面的常用格式。只有用户明确要求负数显示方式、中文日期、12 小时制、累计时长、分数或会计格式时,再选择下面的可选变体。
|
||||
|
||||
可选变体:
|
||||
|
||||
| 用户要求 | 推荐 code | 推荐展示示例 | 可选 code(差异) |
|
||||
| --- | --- | --- | --- |
|
||||
| 负数用括号显示 | `#,##0 ;(#,##0)` | `(1,235)` | `#,##0.00;(#,##0.00)`:保留两位小数,如 `(1,234.50)` |
|
||||
| 负数标红显示 | `#,##0 ;[red](#,##0)` | 红色 `(1,235)` | `#,##0.00;[red](#,##0.00)`:保留两位小数,如红色 `(1,234.50)` |
|
||||
| 分数 | `# ?/?` | `1 1/2` | `# ??/??`:分母最多两位,如 `1 23/32` |
|
||||
| 英文月份日期 | `d-mmm-yy` | `15-Mar-26` | `d-mmm`:省略年份,如 `15-Mar`;`mmm-yy`:只显示月年,如 `Mar-26` |
|
||||
| 中文日期 | `yyyy"年"m"月"d"日"` | `2026年3月15日` | `yyyy"年"m"月"`:只显示年月,如 `2026年3月`;`m"月"d"日"`:只显示月日,如 `3月15日` |
|
||||
| 12 小时制时间 | `h:mm AM/PM` | `2:30 PM` | `h:mm:ss AM/PM`:显示秒,如 `2:30:05 PM` |
|
||||
| 中文上午/下午时间 | `上午/下午 h"时"mm"分"` | `下午 2时30分` | `上午/下午 h"时"mm"分"ss"秒"`:显示秒,如 `下午 2时30分05秒` |
|
||||
| 分秒/累计时长 | `mm:ss` | `05:30` | `[h]:mm:ss`:累计小时,如 `27:05:30`;`mm:ss.0`:显示十分之一秒,如 `05:30.5` |
|
||||
| 人民币负数标红 | `"¥"#,##0_);[red]("¥"#,##0)` | 红色 `(¥1,235)` | `"¥"#,##0.00_);[red]("¥"#,##0.00)`:保留两位小数,如红色 `(¥1,234.50)` |
|
||||
| 美元负数标红 | `$#,##0_);[Red]($#,##0)` | 红色 `($1,235)` | `$#,##0.00_);[Red]($#,##0.00)`:保留两位小数,如红色 `($1,234.50)` |
|
||||
| 会计数字 | `_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)` | `1,235`,零值显示 `-` | `_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)`:保留两位小数,如 `1,234.50` |
|
||||
| 人民币会计格式 | `_("¥"* #,##0_);_("¥"* (#,##0);_("¥"*"-"_);_(@_)` | `¥ 1,235`,零值显示 `¥ -` | `_("¥"* #,##0.00_);_("¥"*(#,##0.00);_("¥"* "-"??_);_(@_)`:保留两位小数,如 `¥ 1,234.50` |
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 4: 写入数据并设置样式 ──
|
||||
|
||||
# 1. 写入数据
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" \
|
||||
--values '[["商品","单价","数量"],["苹果",5.5,100],["香蕉",3.2,200]]' --format json
|
||||
|
||||
# 2. 设置数字格式(人民币)
|
||||
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B3" \
|
||||
--number-format '"¥"#,##0.00' --format json
|
||||
|
||||
# 3. 商品 ID / 规格 ID 按文本展示,避免科学计数法
|
||||
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A3" \
|
||||
--number-format "@" --format json
|
||||
|
||||
# 4. 写入单元格级超链接
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D1" \
|
||||
--values '[[{"type":"text","text":"详情","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]' --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
# ── 工作流 8: 合并单元格 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 合并所有单元格(默认 mergeAll)
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --format json
|
||||
|
||||
# 3. 按行合并
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeRows --format json
|
||||
|
||||
# 4. 按列合并
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeColumns --format json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `merge-cells` | `a1Notation` 实际被合并的范围、`mergeType` 生效的合并方式 | 确认合并结果 |
|
||||
| `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
|
||||
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式
|
||||
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
|
||||
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
|
||||
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
|
||||
- `merge-cells` 的 `--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时忽略 `--sheet-id`
|
||||
- `merge-cells` 如果目标区域与其他合并单元格、锁定区域或表格区域存在交集,合并将失败
|
||||
- `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围
|
||||
- 对已有表格做格式延续、插入列后修复表头、或写入前临时取消合并时,先记录 `sheet info` 返回的 `mergedRanges`,操作后按需用 `merge-cells` 恢复
|
||||
@@ -0,0 +1,242 @@
|
||||
# 表格与工作表管理
|
||||
|
||||
## 使用场景
|
||||
|
||||
用户说"创建表格/新建电子表格":
|
||||
- 创建表格文档 → `create`
|
||||
|
||||
用户说"看工作表/有哪些工作表/表格结构":
|
||||
- 列出工作表 → `list`
|
||||
- 工作表详情 → `info`
|
||||
|
||||
用户说"加工作表/新增Sheet":
|
||||
- 新建工作表 → `new`
|
||||
|
||||
用户说"修改工作表名称/重命名工作表/移动工作表位置/隐藏工作表/显示工作表/冻结行/冻结列/取消冻结/更新工作表属性":
|
||||
- 更新工作表属性 → `update`
|
||||
- 重命名工作表 → `update --name "新名称"`
|
||||
- 移动工作表位置 → `update --index N`
|
||||
- 隐藏工作表 → `update --hidden`
|
||||
- 显示工作表 → `update --hidden=false`
|
||||
- 冻结行列 → `update --frozen-row-count N --frozen-column-count M`
|
||||
- 取消冻结 → `update --frozen-row-count 0 --frozen-column-count 0`
|
||||
|
||||
用户说"复制工作表/拷贝工作表/克隆工作表/工作表副本":
|
||||
- 复制工作表 → `copy`
|
||||
- 复制并指定名称 → `copy --name "副本名称"`
|
||||
- 复制并指定位置 → `copy --index N`
|
||||
|
||||
用户说"删除工作表/移除工作表/删掉这个Sheet":
|
||||
- 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认)
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 创建钉钉表格文档
|
||||
```
|
||||
Usage:
|
||||
dws sheet create [flags]
|
||||
Example:
|
||||
dws sheet create --name "销售数据"
|
||||
dws sheet create --name "Q1 数据" --folder <FOLDER_ID>
|
||||
dws sheet create --name "知识库表格" --workspace <WS_ID>
|
||||
Flags:
|
||||
--name string 表格名称 (必填)
|
||||
--folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId
|
||||
--workspace string 目标知识库 ID
|
||||
```
|
||||
|
||||
> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。
|
||||
|
||||
### 获取全部工作表列表
|
||||
```
|
||||
Usage:
|
||||
dws sheet list [flags]
|
||||
Example:
|
||||
dws sheet list --node <NODE_ID>
|
||||
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
```
|
||||
|
||||
### 获取指定工作表详情
|
||||
```
|
||||
Usage:
|
||||
dws sheet info [flags]
|
||||
Example:
|
||||
dws sheet info --node <NODE_ID>
|
||||
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
dws sheet info --node <NODE_ID> --sheet-id "Sheet1"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (不传则返回第一个工作表)
|
||||
```
|
||||
|
||||
返回字段中 `mergedRanges` 是当前工作表的合并单元格范围列表(A1 表示法,如 `["C7:D11"]`)。它属于工作表结构/布局元数据:读写单元格内容前,如需判断表头、分组标题、续写位置或避开合并冲突,应先看 `sheet info`,不要在 `range read` / `csv-get` 的单元格值里寻找合并信息。
|
||||
|
||||
### 新建工作表
|
||||
```
|
||||
Usage:
|
||||
dws sheet new [flags]
|
||||
Example:
|
||||
dws sheet new --node <NODE_ID> --name "Sheet2"
|
||||
dws sheet new --node <NODE_ID> --name "数据汇总"
|
||||
Flags:
|
||||
--node string 表格文档 ID (必填)
|
||||
--name string 工作表名称 (必填)
|
||||
```
|
||||
|
||||
### 更新工作表属性
|
||||
```
|
||||
Usage:
|
||||
dws sheet update [flags]
|
||||
Example:
|
||||
# 改名 + 调整冻结
|
||||
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --name "汇总表" --frozen-row-count 2 --frozen-column-count 1
|
||||
|
||||
# 隐藏工作表
|
||||
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --hidden=true
|
||||
|
||||
# 显示工作表
|
||||
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --hidden=false
|
||||
|
||||
# 移动工作表到第一个位置
|
||||
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --index 0
|
||||
|
||||
# 取消冻结
|
||||
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --frozen-row-count 0 --frozen-column-count 0
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--name string 新名称,最长 100 字符,不能包含 / \ ? * [ ] :
|
||||
--index int 新位置(从 0 开始)
|
||||
--hidden --hidden=true 隐藏,--hidden=false 取消隐藏
|
||||
--frozen-row-count int 冻结行数,0 表示取消冻结
|
||||
--frozen-column-count int 冻结列数,0 表示取消冻结
|
||||
```
|
||||
|
||||
更新工作表名称、位置、隐藏状态、冻结行列。
|
||||
`--name` / `--index` / `--hidden` / `--frozen-row-count` / `--frozen-column-count` 至少提供一个;多个属性可同时传入,将在同一次请求中更新。
|
||||
|
||||
注意:
|
||||
- 至少需要保留一个可见的工作表,不能将所有工作表都隐藏
|
||||
- 冻结行数/列数不能超过工作表的总行数/列数
|
||||
|
||||
### 复制工作表
|
||||
```
|
||||
Usage:
|
||||
dws sheet copy [flags]
|
||||
Example:
|
||||
# 按默认位置复制
|
||||
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
|
||||
# 指定副本名称和位置
|
||||
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID> --name "销售副本" --index 2
|
||||
|
||||
# 只指定名称
|
||||
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID> --name "备份"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 源工作表 ID 或名称 (必填)
|
||||
--name string 副本名称,最长 100 字符,不能包含 / \ ? * [ ] : (不传则系统自动生成)
|
||||
--index int 副本位置(从 0 开始)(不传则放在源工作表之后)
|
||||
```
|
||||
|
||||
复制指定工作表,在同一表格中创建一个副本。
|
||||
复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表中。
|
||||
传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引。
|
||||
名称与已有工作表重复时系统会自动重命名。
|
||||
|
||||
### 删除工作表
|
||||
```
|
||||
Usage:
|
||||
dws sheet delete-sheet [flags]
|
||||
Example:
|
||||
dws sheet delete-sheet --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 要删除的工作表 ID 或名称 (必填)
|
||||
```
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
删除指定的工作表及其所有数据。约束:
|
||||
- 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除)
|
||||
- 不能删除最后一个可见工作表(至少保留一个可见工作表)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 1: 创建表格并写入数据 ──
|
||||
|
||||
# 1. 创建表格文档 — 提取 nodeId
|
||||
dws sheet create --name "销售数据" --format json
|
||||
|
||||
# 2. 查看工作表列表 — 提取 sheetId
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 3. 写入表头和数据
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C1" \
|
||||
--values '[["姓名","部门","销售额"]]' --format json
|
||||
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:C4" \
|
||||
--values '[["张三","销售部",50000],["李四","市场部",38000],["王五","销售部",62000]]' --format json
|
||||
|
||||
# ── 工作流 2: 读取已有表格数据 ──
|
||||
|
||||
# 1. 获取工作表列表
|
||||
dws sheet list --node <NODE_ID> --format json
|
||||
|
||||
# 2. 查看工作表详情(行列数、最后非空位置等)
|
||||
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 3. 读取全部数据
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 4. 读取指定区域
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
|
||||
|
||||
# ── 工作流 3: 多工作表管理 ──
|
||||
|
||||
# 1. 新建工作表
|
||||
dws sheet new --node <NODE_ID> --name "汇总" --format json
|
||||
|
||||
# 2. 在新工作表中写入汇总公式
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <NEW_SHEET_ID> --range "A1:B1" \
|
||||
--values '[["指标","数值"]]' --format json
|
||||
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <NEW_SHEET_ID> --range "A2:B2" \
|
||||
--values '[["总销售额","=SUM(Sheet1!C2:C100)"]]' --format json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `create` | `nodeId` | list / info / new / range read / range update / find 的 --node |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
| `new` | 新工作表的 `sheetId` | range read / range update / find 的 --sheet-id |
|
||||
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、判断合并单元格结构 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:所有涉及 `--sheet-id` 参数的命令,除非用户主动提供了工作表 ID 或工作表名称,否则在 `sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
|
||||
- `mergedRanges` 中的范围表示一个整体语义区域。合并区域内非左上角单元格为空并不代表无内容,通常应以左上角单元格的值作为该合并区域的含义。
|
||||
- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录
|
||||
- `list` 返回所有工作表的 ID 和名称,是后续操作的必要前置步骤
|
||||
- `info` 不传 `--sheet-id` 时默认返回第一个工作表的详情
|
||||
- `new` 创建工作表时,如名称与已有工作表重复,系统会自动重命名
|
||||
- `update` 的 `--name`、`--index`、`--hidden`、`--frozen-row-count`、`--frozen-column-count` 至少必须提供一个
|
||||
- `update` 的 `--name` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符
|
||||
- `update` 的 `--index` 为 0-based 非负整数,0 表示移动到最前面
|
||||
- `update` 的 `--hidden` 设为 true 时,至少需要保留一个可见的工作表,不能将所有工作表都隐藏
|
||||
- `update` 的 `--frozen-row-count` / `--frozen-column-count` 为非负整数,不能超过工作表的总行数/列数,设为 0 表示取消冻结
|
||||
- `update` 当同时提供多个属性时,所有属性将在同一次请求中更新
|
||||
- `copy` 复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表
|
||||
- `copy` 的 `--name` 可选,不传时系统自动生成名称(通常为"源名称 副本"或类似格式)
|
||||
- `copy` 的 `--name` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符
|
||||
- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值
|
||||
- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置
|
||||
- `delete-sheet` 为不可逆操作,执行前必须向用户确认
|
||||
- `delete-sheet` 不能删除隐藏的工作表,需先通过 `update --hidden=false` 取消隐藏再删除
|
||||
- `delete-sheet` 不能删除最后一个可见工作表,至少保留一个可见工作表
|
||||
- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user