Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f3aa7f8f | ||
|
|
a30ff648f4 | ||
|
|
e924d11db6 | ||
|
|
7c1edadd4d | ||
|
|
2699d2cfbe |
@@ -20,15 +20,16 @@ metadata:
|
||||
1. 命中下方高频意图时直接使用精确骨架,不先查 Help 或产品级 Schema。
|
||||
2. 路由优先级固定为:精确 recipe / 可运行脚本 > 匹配的公开 Shortcut > 原子命令。命令已确定且参数清楚时直接执行。
|
||||
3. 参数、约束或安全语义不确定时只读 leaf Schema:`dws schema --cli-path "aitable <leaf>" --format json`;只有当前 Cobra flag 不确定时才读对应 `--help`。
|
||||
4. 复杂字段、筛选、导入导出、视图、权限或工作流任务,按“低频能力与 Reference”只加载相关文件,不预读整个 `references/aitable/`。
|
||||
4. 复杂任务按下表只读相关 Reference;路径相对本 Skill 根目录且必须保留 `aitable/`。脚本型任务只读 `references/aitable/aitable-script-recipes.md`,再运行 `scripts/aitable_ops.py`;不预读目录或源码。
|
||||
5. 现有骨架和 reference 都无法定位能力时,才用 Runtime Shortcut Catalog 做最后发现;不得猜 `cli_path` 或 flag。
|
||||
6. Schema、Help、reference 与实际返回冲突时采用更安全的解释并报告契约漂移;`confirmation=user_required` 时先确认,再添加 `--yes`。
|
||||
7. 用户已给足名称、字段、数据和目标时,直接按依赖链完成全部步骤;不要调用 todo 工具、分步汇报或追问已明确的信息。中间返回只用于提取下一步 ID 和判断失败,完成所有请求后再统一回读并答复。
|
||||
7. 输入完整时直接完成依赖链,不调用 todo、不分步汇报或重复追问;中间响应只提取 ID/错误,最后统一回读答复。脚本参数明确就执行;不明确才读操作级 `--help`,仅契约失败、环境异常或用户要求修改时读源码。
|
||||
8. 用户要求新建 Base 但未指定 Base 名时,根据业务目标生成简短描述性名称(例如仪表盘任务用“数据看板”)并继续;不要仅为可回退的容器名称追问。Base 只接受 Base flags,不得把 table `--fields` 传给 `base create`。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcut 发现(按需)
|
||||
|
||||
`aitable` 当前有 29 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
29 条公开 shortcut 保留在 Runtime Catalog,不在根 Skill 展开。已知意图直接走下方路由;仅参数/安全语义不确定时读 leaf Schema,Cobra flags 不确定时读 leaf Help。
|
||||
|
||||
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
@@ -60,19 +61,19 @@ metadata:
|
||||
| 新增记录 | `dws aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":<值>}}]' --format json` | 单次最多 100;取 `data.newRecordIds[]` 后立即按 ID 回读 |
|
||||
| 更新记录 | `dws aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<id>","cells":{"<fieldId>":<值>}}]' --format json` | 先 query 拿 recordId;只传需改字段;取 `data.recordIds[]` 后回读 |
|
||||
| 删除记录 | 先 `dws aitable +record-query ...` 定位,再 `dws aitable record delete --base-id <baseId> --table-id <tableId> --record-ids <ids>` | 展示目标与影响,得到明确确认后才加 `--yes` |
|
||||
| 创建 Base / Table | `dws aitable base create --name "<名>"` / `dws aitable table create --base-id <id> --name "<名>" --fields '[...]'` | 使用创建返回的真实 ID;系统改名/加后缀时不得继续猜原名 |
|
||||
| 创建 Base / Table | `dws aitable base create --name "<名>" --format json` → `dws aitable base get --base-id <baseId> --format json`;`dws aitable table create --base-id <id> --name "<名>" --fields '[...]' --format json` → `dws aitable +table-get --base-id <id> --table-ids <tableId> --format json` | 使用创建返回的真实 ID 立即回读;创建字段时回读 `fields[]` 的名称、类型与 config;系统改名/加后缀时不得继续猜原名 |
|
||||
| 创建仪表盘 / 常用图表 | `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<仪表盘名>" [--chart-specs-file <workspace内JSON文件>]` | 唯一首选;完整参数与 ledger 契约只读 `references/aitable/aitable-script-recipes.md` |
|
||||
| 复制视图 | `dws aitable view duplicate --base-id <baseId> --table-id <tableId> --view-id <源viewId> --new-name "<新名称>" --format json` | 源 viewId 来自当前表的真实返回;不要复制数据表或创建仪表盘替代 |
|
||||
| 批量追加 CSV / JSON 到已有表 | `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]` | CSV 表头必须是 fieldId;脚本返回不完整 ledger 时不得宣称全成功 |
|
||||
| 文件导入为新数据表 | `python3 scripts/aitable_import_via_task.py <baseId> <file>` | 与“追加已有 table”不同;走 prepare → PUT → import task |
|
||||
| 批量创建字段 | `python3 scripts/bulk_add_fields.py <baseId> <tableId> fields.json` | 单次最多 15;逐项检查成功/失败结果 |
|
||||
| 导出 Base / Table / View | `python3 scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view [...]` | 保存路径、覆盖与异步未完成状态必须显式处理 |
|
||||
| 上传记录附件 | `python3 scripts/upload_attachment.py <baseId> <file>` | 返回 `fileToken` 后仍需按字段格式写入记录并回读 |
|
||||
| 导入 / 导出 / 批量字段 / 附件 | 先读 `references/aitable/aitable-script-recipes.md`,再运行其中唯一的 `scripts/aitable_ops.py <operation> ...` | 不直接选择底层脚本;不读源码;保留统一入口返回的退出状态与 ledger |
|
||||
|
||||
导出完成以统一 ledger 中的真实 `taskId`、`polledTimes`、`status=success`、`fileSize>0` 和 `savedPath` 为证据;不要自己重新轮询、读脚本源码,也不要只用 `ls` 替代异步任务证据。字段类型使用 Runtime camelCase,例如 `singleSelect` / `multipleSelect`;select 写值优先传选项名字符串或 `{id,name}`,不传 `{value:...}`。
|
||||
|
||||
## 记录读写不变量
|
||||
|
||||
- `record create/update` 前必须获取目标字段的 `fieldId`、`type` 与 `config`;`filterUp`、`lookup` 等只读字段不可写。完整格式只在需要时读 [aitable-cell-value.md](references/aitable/aitable-cell-value.md)。
|
||||
- 筛选和排序字段使用 `fieldId`;`--filters` 最外层是 `and|or + operands`,`--sort` 使用 `direction: asc|desc`。日期和跨表字段规则按需读 [aitable-filter-sort.md](references/aitable/aitable-filter-sort.md)。
|
||||
- `record query --all` 仍受 `--page-limit` 约束;分页中断或局部富化失败时保留已有结果,输出 completeness 与逐项失败 ledger,不把部分结果描述为全量。
|
||||
- `record create/update` 前必须获取目标字段的 `fieldId`、`type` 与 `config`;`filterUp`、`lookup` 等只读字段不可写。完整格式只在需要时读精确路径 `references/aitable/aitable-cell-value.md`。
|
||||
- 筛选和排序字段使用 `fieldId`;`--filters` 最外层是 `and|or + operands`,`--sort` 使用 `direction: asc|desc`。日期和跨表字段规则按需读精确路径 `references/aitable/aitable-filter-sort.md`。
|
||||
- **Complete evidence:** if an exact total/set relies on paginated output, use `--all --page-limit 0` or iterate until `nextCursor` is empty and `hasMore` is false. A complete write-result ID list is sufficient; a truncated page is not.
|
||||
- **Capability proof:** with write authorization, create minimal prerequisites, execute, and read back. Help/Schema alone is unverified.
|
||||
- 创建、更新、导入、批量建字段等写操作必须检查业务 `status`、逐项结果与返回 ID;普通写入按用户明确要求执行后回读,不能只凭退出码宣称成功。
|
||||
- 长 JSON 使用 `--records-file` / 任务文件;不得为绕过字段错误而静默丢列、改类型或删除失败项。
|
||||
|
||||
@@ -80,19 +81,20 @@ metadata:
|
||||
|
||||
| 场景 | 按需读取 |
|
||||
|---|---|
|
||||
| 完整命令索引、对象 URL 与一级路由 | [aitable.md](references/aitable.md) |
|
||||
| 记录 query/create/update/delete/upsert/history/share | 对应 `references/aitable/aitable-record-*.md` |
|
||||
| 字段创建、字段 config、cellValue、公式与跨表引用 | [aitable-field.md](references/aitable/aitable-field.md)、[aitable-field-properties.md](references/aitable/aitable-field-properties.md)、[aitable-cell-value.md](references/aitable/aitable-cell-value.md)、[aitable-formula-guide.md](references/aitable/aitable-formula-guide.md) |
|
||||
| 筛选、排序、统计、全量分析 | [aitable-filter-sort.md](references/aitable/aitable-filter-sort.md)、[aitable-data-analysis-sop.md](references/aitable/aitable-data-analysis-sop.md) |
|
||||
| 导入导出、附件 | [aitable-export-import.md](references/aitable/aitable-export-import.md)、[aitable-attachment.md](references/aitable/aitable-attachment.md) |
|
||||
| 视图、表单、仪表盘与图表 | [aitable-view-config.md](references/aitable/aitable-view-config.md)、[aitable-view-extras.md](references/aitable/aitable-view-extras.md)、[aitable-form.md](references/aitable/aitable-form.md)、[aitable-dashboard-chart.md](references/aitable/aitable-dashboard-chart.md) |
|
||||
| 高级权限、自动化工作流、导航节点 | [aitable-advperm.md](references/aitable/aitable-advperm.md)、[aitable-workflow.md](references/aitable/aitable-workflow.md)、[aitable.md](references/aitable.md) 的 section 路由 |
|
||||
| 完整命令索引、对象 URL 与一级路由 | `references/aitable.md` |
|
||||
| 记录 query/create/update/delete | `references/aitable/aitable-record-query.md`、`references/aitable/aitable-record-create.md`、`references/aitable/aitable-record-update.md`、`references/aitable/aitable-record-delete.md` 中只读与当前动词一致的一份 |
|
||||
| 记录 upsert/history/share | `references/aitable/aitable-record-upsert.md`、`references/aitable/aitable-record-history.md`、`references/aitable/aitable-record-share.md` 中只读与当前动词一致的一份 |
|
||||
| 字段创建、字段 config、cellValue、公式与跨表引用 | `references/aitable/aitable-field.md`、`references/aitable/aitable-field-properties.md`、`references/aitable/aitable-cell-value.md`、`references/aitable/aitable-formula-guide.md` |
|
||||
| 筛选、排序、统计、全量分析 | `references/aitable/aitable-filter-sort.md`、`references/aitable/aitable-data-analysis-sop.md` |
|
||||
| dashboard/chart、导入导出、批量字段、附件脚本 | `references/aitable/aitable-script-recipes.md`(精确路径;只读这一份脚本契约) |
|
||||
| 视图、表单及高级 dashboard/chart 原子回退 | `references/aitable/aitable-view-config.md`、`references/aitable/aitable-view-extras.md`、`references/aitable/aitable-form.md`、`references/aitable/aitable-dashboard-chart.md` |
|
||||
| 高级权限、自动化工作流、导航节点 | `references/aitable/aitable-advperm.md`、`references/aitable/aitable-workflow.md`、`references/aitable.md` 的 section 路由 |
|
||||
|
||||
## 错误恢复
|
||||
|
||||
- 路径或 flag 错误:按既定的 leaf Schema → leaf Help 顺序校正一次;仍失败则停止,不连续尝试猜测别名。
|
||||
- 命令非零、输出非 JSON、业务 `status != success`、必需 ID 缺失、批处理部分失败均视为失败;保留成功项与 ledger,禁止吞错。
|
||||
- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读 [aitable-error-recovery.md](references/aitable/aitable-error-recovery.md)。
|
||||
- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读精确路径 `references/aitable/aitable-error-recovery.md`。
|
||||
- 每次重试都从最新实际输出重新提取下游 ID;删除和其他 `confirmation=user_required` 操作不得自动重试或静默确认。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
|--------|-------------------|
|
||||
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. `aitable record query --base-id <baseId> --table-id <tableId>` → 取记录(分页)<br> 需要筛选时 `--filters` 格式见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md),根节点必须是 `{"operator":"and\|or","operands":[...]}`<br>4. 总结数据 |
|
||||
| generate-data-report | 1. 同 read-aitable 步骤 1-3<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
|
||||
| create-aitable-record | **批量导入优先**:`python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(自动分批创建)<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
|
||||
| create-aitable-record | **批量导入优先**:`python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> data.csv\|data.json [--batch-size N]`<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
|
||||
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable record query --base-id <baseId> --table-id <tableId>` → 取 `recordId`,**先展示让用户确认**<br>3. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'` |
|
||||
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
|
||||
|
||||
@@ -102,11 +102,11 @@ dws aitable record delete --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|
||||
> **不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。
|
||||
|
||||
使用 `upload_attachment.py` 脚本(内部自动完成 prepare + PUT to OSS),**2 步**完成:
|
||||
使用统一 `aitable_ops.py upload-attachment` 入口(内部委派 prepare + PUT to OSS),**2 步**完成:
|
||||
|
||||
```bash
|
||||
# 步骤 1: 一键上传文件
|
||||
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
|
||||
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/report.pdf
|
||||
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
|
||||
|
||||
# 步骤 2: 在 record create/update 中使用 fileToken 写入附件字段
|
||||
|
||||
@@ -481,11 +481,7 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|
||||
| 脚本 | 场景 |
|
||||
|------|------|
|
||||
| [bulk_add_fields.py](../scripts/bulk_add_fields.py) | 批量添加字段 |
|
||||
| [import_records.py](../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 |
|
||||
| [aitable_import_via_task.py](../scripts/aitable_import_via_task.py) | 导入 CSV/XLS/XLSX 并新建数据表(prepare + PUT + import) |
|
||||
| [aitable_export_via_task.py](../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) |
|
||||
| [upload_attachment.py](../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 |
|
||||
| `scripts/aitable_ops.py` | dashboard/chart、导入、导出、批量字段与附件的唯一稳定入口;参数见 `references/aitable/aitable-script-recipes.md` |
|
||||
|
||||
## 相关产品
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Flags:
|
||||
|
||||
```bash
|
||||
# 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT)
|
||||
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
|
||||
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/report.pdf
|
||||
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
|
||||
|
||||
# 步骤 2: 在 record create/update 中使用 fileToken 写入
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
# dashboard & chart — 仪表盘与图表
|
||||
|
||||
## 建议操作顺序
|
||||
## 创建首选流程
|
||||
|
||||
```bash
|
||||
# 1) 先看配置模板(JSONC)
|
||||
dws aitable dashboard config-example --format json
|
||||
dws aitable chart widgets-example --format json
|
||||
# 仅建仪表盘
|
||||
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>"
|
||||
|
||||
# 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
|
||||
# 建仪表盘和常用图表
|
||||
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>" \
|
||||
--chart-specs-file <workspace内/charts.json>
|
||||
```
|
||||
|
||||
## 要点
|
||||
统一入口的 dashboard 操作是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选
|
||||
recipe,并输出 `dws-skill-script-ledger/v1`。不要在脚本前调用 config-example 或
|
||||
widgets-example,也不要在成功后重复创建或回读。
|
||||
|
||||
`charts.json` 是 1–6 项数组,每项参数:
|
||||
|
||||
| 参数 | 要求 |
|
||||
|---|---|
|
||||
| `name` | 必填,图表名 |
|
||||
| `chart_type` | 必填:`AREA`、`BAR`、`HISTOGRAM`、`LINE`、`PIE`、`STATISTICS` |
|
||||
| `table_id` | 必填,当前链路的真实 tableId |
|
||||
| `measure_type` | `record-count`(默认)或 `field` |
|
||||
| `measure_field_id` | `measure_type=field` 时必填 |
|
||||
| `dimension_field_id` | 分组、分类或时间维度需要时填写 |
|
||||
| `aggregation` | 可选:`sum`、`count`、`count_distinct`、`average`、`min`、`max` |
|
||||
| `view_id` | 可选;不用视图时省略 |
|
||||
|
||||
例如按状态统计记录数:
|
||||
|
||||
```json
|
||||
[{"name":"跟进状态记录数","chart_type":"HISTOGRAM","table_id":"<tableId>","measure_type":"record-count","dimension_field_id":"<状态fieldId>"}]
|
||||
```
|
||||
|
||||
脚本不支持的图表类型或完整高级配置才走下方原子命令;这种例外至多读取一次
|
||||
`chart widgets-example`,再按真实 tableId/fieldId 构造配置。
|
||||
|
||||
## 查询与管理要点
|
||||
|
||||
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
|
||||
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
|
||||
@@ -26,7 +51,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
|
||||
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
|
||||
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
|
||||
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
|
||||
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 仅脚本不支持的高级配置按需读取一次 |
|
||||
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
|
||||
|
||||
## chart 子命令
|
||||
@@ -34,11 +59,10 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
|
||||
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
|
||||
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
|
||||
| `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` | 查看图表 widgets 配置模板 | 无;返回很大 | 仅脚本不支持的高级图表按需读取一次 |
|
||||
|
||||
## 配置获取流程
|
||||
|
||||
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
|
||||
原子 `chart create` 必须同时传 `--layout`。返回 chartId 后用 `chart get`,或最后
|
||||
一次 `dashboard get` 核对;回读成功即停止。脚本已自动完成这些动作,不再重复执行。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
|
||||
|
||||
优先使用 `python3 scripts/aitable_export_via_task.py <baseId> --scope all|table|view [...]`:它检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。
|
||||
优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py export <baseId> --scope all|table|view [...]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。
|
||||
|
||||
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
|
||||
|
||||
@@ -30,7 +30,7 @@ dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 300
|
||||
|
||||
> **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。
|
||||
|
||||
新建数据表导入优先使用 `python3 scripts/aitable_import_via_task.py <baseId> <file>`,脚本封装 prepare → PUT → import 并检查每一步业务状态。追加到已有表且需要字段级类型控制时,使用 `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]`;二者语义不同,不要自动互换。
|
||||
新建数据表导入优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-new <baseId> <file>`;追加到已有表使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;二者语义不同,不要自动互换。
|
||||
|
||||
```bash
|
||||
# 第 1 步:申请上传凭证
|
||||
|
||||
@@ -87,6 +87,8 @@ dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|
||||
创建 `lookup`(关联引用)和 `filterUp`(查找引用)字段时,config 格式有严格要求:
|
||||
|
||||
当用户要求判断此能力且已授权创建资源时,不要只读 Help/Schema 后下结论:创建最小目标表及必需字段,执行一次真实字段创建并用 `field get` 回读。若没有写入授权,只能报告“接口声明支持但未实际验证”。
|
||||
|
||||
#### bidirectionalLink / unidirectionalLink(关联字段)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -47,7 +47,7 @@ dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|
||||
创建成功以 `data.newRecordIds[]` 为 ID 来源;不要把整个 `data` 当作单个 recordId,也不要只以退出码作为写入成功证据。
|
||||
|
||||
批量追加本地 JSON/CSV 到已有表时可使用 `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]`。脚本会逐批检查业务状态、收集 `newRecordIds` 并按 ID 回读;任何批次失败或回读不完整都会输出 ledger 并以非零状态结束,已有成功批次仍会保留在结果中。
|
||||
批量追加本地 JSON/CSV 到已有表时使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口保留逐批业务状态、`newRecordIds` 回读和部分失败 ledger。
|
||||
|
||||
## cells 写入格式
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ Flags:
|
||||
- **被截断时**(达到 page-limit 但仍有数据):输出中包含 `"hasMore": true` 和 `"cursor": "..."` 字段,可通过 `--cursor` 从断点继续拉取
|
||||
- 适用于需要一次性获取全量数据的场景(如导出、统计、批量处理)
|
||||
|
||||
当精确计数或集合依赖分页查询时,优先使用 `--all --page-limit 0`;否则持续传回 `nextCursor`/`cursor`,直到游标为空且 `hasMore` 不为 true。写入响应返回的完整 ID 列表可直接作为该批证据;截断页、局部计数或最终链接数不能替代分页完成信号。
|
||||
|
||||
```bash
|
||||
# 默认(最多 50 页 = 5000 条)
|
||||
dws aitable record query --base-id X --table-id Y --all
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Bundled script recipes
|
||||
|
||||
Use this file only for dashboard/chart, import, export, bulk fields, or attachment workflows.
|
||||
Use `scripts/aitable_ops.py` as the stable entry; do not read implementation files. Use operation-level `--help` only for unclear arguments.
|
||||
|
||||
For create-Base-then-script, create and verify the Base, then pass its returned `baseId`; choose a short name if omitted.
|
||||
|
||||
| Intent | Exact command |
|
||||
|---|---|
|
||||
| Create dashboard, optionally charts | `python3 <Skill绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<dashboardName>" [--chart-specs-file <workspace JSON file>]` |
|
||||
| Import CSV/XLS/XLSX as a new table | `python3 <Skill绝对目录>/scripts/aitable_ops.py import-new <baseId> <file>` |
|
||||
| Append JSON/CSV to an existing table | `python3 <Skill绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size 100]` |
|
||||
| Export Base/table/view | `python3 <Skill绝对目录>/scripts/aitable_ops.py export <baseId> --scope all\|table\|view [--table-id <id>] [--view-id <id>] [--output <path>]` |
|
||||
| Add up to 15 fields | `python3 <Skill绝对目录>/scripts/aitable_ops.py add-fields <baseId> <tableId> <fields.json>` |
|
||||
| Upload attachment | `python3 <Skill绝对目录>/scripts/aitable_ops.py upload-attachment <baseId> <file>` |
|
||||
|
||||
## Contracts
|
||||
|
||||
- `dashboard` creates the dashboard/charts, chains returned IDs, performs final readback, and emits `dws-skill-script-ledger/v1`. `--chart-specs-file` is a workspace-local file containing one JSON array; it is not inline JSON. Each item accepts `name`, `chart_type`, `table_id`, `measure_type`, `measure_field_id`, `dimension_field_id`, `aggregation`, and `view_id`. Do not add a guessed dashboard command after it.
|
||||
- `import-new` runs prepare → secure PUT → import task and checks each business status. A returned HTTP upload URL is upgraded to HTTPS without changing host, path, or query. It is not interchangeable with `import-records`, and a deterministic failure is not a reason to expand the same flow manually.
|
||||
- `import-records` requires CSV headers to be field IDs; use JSON for typed boolean/array/object values. It checks batch results and reads back returned record IDs.
|
||||
- `export --scope table|view` requires `--table-id`; view also requires `--view-id`. The unified ledger exposes the real `taskId`, `polledTimes`, `savedPath`, and `fileSize`; treat success plus a non-empty saved file as the completion evidence, without reading source or substituting `ls` for task polling. Do not overwrite unless the user explicitly requests it.
|
||||
- `add-fields` accepts at most 15 items and reports partial failure. `upload-attachment` only returns `fileToken`; write that token to the attachment field and read the record back.
|
||||
- Preserve nonzero exit, partial-success ledger, timeout, and incomplete readback in the final answer. Do not replace a failed deterministic workflow with guessed atomic commands unless its reported error proves that the script contract is unavailable.
|
||||
@@ -91,11 +91,11 @@ dws aitable table create --base-id <BASE_ID> --name "产品图片" \
|
||||
|
||||
> **不要**使用钉盘 (drive) 上传来替代此流程!钉盘 fileId **无法**写入 attachment 字段。
|
||||
|
||||
附件字段写入使用 `upload_attachment.py` 脚本,**2 步**完成:
|
||||
附件字段写入使用统一 `aitable_ops.py upload-attachment` 入口,**2 步**完成:
|
||||
|
||||
```bash
|
||||
# 步骤 1: 一键上传文件(脚本内部自动完成 prepare + PUT to OSS)
|
||||
python3 scripts/upload_attachment.py <BASE_ID> /path/to/photo.png
|
||||
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/photo.png
|
||||
# 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 }
|
||||
|
||||
# 步骤 2: 在 record create/update 中使用 fileToken
|
||||
|
||||
@@ -26,7 +26,8 @@ from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
|
||||
# Runtime AI Table IDs are opaque and some table IDs are seven characters.
|
||||
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,128}$")
|
||||
ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
|
||||
@@ -52,12 +52,24 @@ def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
|
||||
def normalize_upload_url(upload_url: str) -> str:
|
||||
parsed = urlparse(upload_url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return False, "uploadUrl must be a valid HTTPS URL"
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ValueError("uploadUrl must be a valid HTTP(S) URL")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("uploadUrl must not contain user info")
|
||||
if parsed.scheme == "http":
|
||||
parsed = parsed._replace(scheme="https")
|
||||
return urlunparse(parsed)
|
||||
|
||||
|
||||
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
|
||||
try:
|
||||
secure_upload_url = normalize_upload_url(upload_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
payload = file_path.read_bytes()
|
||||
req = Request(upload_url, data=payload, method="PUT")
|
||||
req = Request(secure_upload_url, data=payload, method="PUT")
|
||||
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
|
||||
req.add_header("Content-Type", "")
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stable black-box entry point for bundled AI Table workflows."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
LEDGER_SCHEMA_VERSION = "dws-skill-script-ledger/v1"
|
||||
|
||||
|
||||
def command_for(args: argparse.Namespace) -> list[str]:
|
||||
if args.operation == "dashboard":
|
||||
command = [
|
||||
"create_dashboard_chart.py", args.base_id, args.dashboard_name,
|
||||
*(["--chart-specs-file", args.chart_specs_file] if args.chart_specs_file else []),
|
||||
]
|
||||
elif args.operation == "import-new":
|
||||
command = ["aitable_import_via_task.py", args.base_id, args.file]
|
||||
elif args.operation == "import-records":
|
||||
command = [
|
||||
"import_records.py", args.base_id, args.table_id, args.file,
|
||||
str(args.batch_size),
|
||||
]
|
||||
elif args.operation == "export":
|
||||
command = [
|
||||
"aitable_export_via_task.py", args.base_id, "--scope", args.scope,
|
||||
*(["--table-id", args.table_id] if args.table_id else []),
|
||||
*(["--view-id", args.view_id] if args.view_id else []),
|
||||
*(["--output", args.output] if args.output else []),
|
||||
*(["--export-format", args.export_format] if args.export_format else []),
|
||||
*(["--overwrite"] if args.overwrite else []),
|
||||
]
|
||||
elif args.operation == "add-fields":
|
||||
command = [
|
||||
"bulk_add_fields.py", args.base_id, args.table_id, args.fields_file,
|
||||
]
|
||||
else:
|
||||
command = ["upload_attachment.py", args.base_id, args.file]
|
||||
return [sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]]
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
root = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Run a bundled AI Table workflow without reading implementation source. "
|
||||
"Each operation preserves the underlying script ledger and exit status."
|
||||
)
|
||||
)
|
||||
sub = root.add_subparsers(dest="operation", required=True)
|
||||
|
||||
dashboard = sub.add_parser("dashboard", help="create and read back a dashboard/chart")
|
||||
dashboard.add_argument("base_id")
|
||||
dashboard.add_argument("dashboard_name")
|
||||
dashboard.add_argument(
|
||||
"--chart-specs-file", "--chart-specs", dest="chart_specs_file",
|
||||
help="workspace-local file containing a JSON array of chart specs",
|
||||
)
|
||||
|
||||
import_new = sub.add_parser("import-new", help="import CSV/XLS/XLSX as a new table")
|
||||
import_new.add_argument("base_id")
|
||||
import_new.add_argument("file")
|
||||
|
||||
import_records = sub.add_parser("import-records", help="append JSON/CSV records to an existing table")
|
||||
import_records.add_argument("base_id")
|
||||
import_records.add_argument("table_id")
|
||||
import_records.add_argument("file")
|
||||
import_records.add_argument("--batch-size", type=int, default=100)
|
||||
|
||||
export = sub.add_parser("export", help="export a Base, table, or view")
|
||||
export.add_argument("base_id")
|
||||
export.add_argument("--scope", choices=("all", "table", "view"), required=True)
|
||||
export.add_argument("--table-id")
|
||||
export.add_argument("--view-id")
|
||||
export.add_argument("--output")
|
||||
export.add_argument(
|
||||
"--export-format",
|
||||
choices=("attachment", "excel", "excel_and_attachment", "excel_with_inline_images"),
|
||||
)
|
||||
export.add_argument("--overwrite", action="store_true")
|
||||
|
||||
fields = sub.add_parser("add-fields", help="create up to 15 fields from JSON")
|
||||
fields.add_argument("base_id")
|
||||
fields.add_argument("table_id")
|
||||
fields.add_argument("fields_file")
|
||||
|
||||
attachment = sub.add_parser("upload-attachment", help="upload a file and return fileToken")
|
||||
attachment.add_argument("base_id")
|
||||
attachment.add_argument("file")
|
||||
return root
|
||||
|
||||
|
||||
def normalize_output(raw: str, args: argparse.Namespace | None = None) -> str:
|
||||
"""Make a delegated trusted ledger attributable to this stable entry point."""
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
if (
|
||||
isinstance(payload, dict)
|
||||
and payload.get("schema_version") == LEDGER_SCHEMA_VERSION
|
||||
and isinstance(payload.get("script"), str)
|
||||
):
|
||||
payload["implementation_script"] = payload["script"]
|
||||
payload["script"] = Path(__file__).name
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
if args and args.operation == "export" and isinstance(payload, dict):
|
||||
status = str(payload.get("status") or "error")
|
||||
saved_path = str(payload.get("savedPath") or "")
|
||||
file_size = 0
|
||||
if saved_path:
|
||||
try:
|
||||
file_size = Path(saved_path).stat().st_size
|
||||
except OSError:
|
||||
file_size = 0
|
||||
task_id = str(payload.get("taskId") or "")
|
||||
ledger_status = "success" if status == "success" and task_id and (
|
||||
bool(payload.get("downloadUrl")) and (not saved_path or file_size > 0)
|
||||
) else status
|
||||
ledger = {
|
||||
"schema_version": LEDGER_SCHEMA_VERSION,
|
||||
"script": Path(__file__).name,
|
||||
"implementation_script": "aitable_export_via_task.py",
|
||||
"status": ledger_status,
|
||||
"result": payload,
|
||||
"ledger": [{
|
||||
"cli_path": "aitable export data",
|
||||
"status": ledger_status,
|
||||
"params": {
|
||||
"base-id": args.base_id,
|
||||
"scope": args.scope,
|
||||
**({"table-id": args.table_id} if args.table_id else {}),
|
||||
**({"view-id": args.view_id} if args.view_id else {}),
|
||||
},
|
||||
"output_ids": {
|
||||
"taskId": task_id,
|
||||
"polledTimes": int(payload.get("polledTimes") or 0),
|
||||
"savedPath": saved_path,
|
||||
"fileSize": file_size,
|
||||
},
|
||||
"error": "" if ledger_status == "success" else str(payload.get("summary") or "export incomplete"),
|
||||
}],
|
||||
}
|
||||
return json.dumps(ledger, ensure_ascii=False)
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
if args.operation == "export":
|
||||
if args.scope in {"table", "view"} and not args.table_id:
|
||||
parser().error("export --scope table|view requires --table-id")
|
||||
if args.scope == "view" and not args.view_id:
|
||||
parser().error("export --scope view requires --view-id")
|
||||
result = subprocess.run(command_for(args), check=False, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(normalize_output(result.stdout, args), end="" if result.stdout.endswith("\n") else "\n")
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="" if result.stderr.endswith("\n") else "\n")
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an AI Table dashboard and optional common charts deterministically.
|
||||
|
||||
Examples:
|
||||
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘"
|
||||
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘" --chart-specs-file charts.json
|
||||
|
||||
charts.json is a JSON array. Each item accepts:
|
||||
name, chart_type, table_id, measure_type, measure_field_id,
|
||||
dimension_field_id, aggregation, view_id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
LEDGER_SCHEMA_VERSION = "dws-skill-script-ledger/v1"
|
||||
SCRIPT_NAME = "create_dashboard_chart.py"
|
||||
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,128}$")
|
||||
SUPPORTED_CHART_TYPES = {"AREA", "BAR", "HISTOGRAM", "LINE", "PIE", "STATISTICS"}
|
||||
SUPPORTED_AGGREGATIONS = {"sum", "count", "count_distinct", "average", "avg", "min", "max"}
|
||||
MAX_CHARTS = 6
|
||||
|
||||
|
||||
def run_dws(dws_bin: str, args: list[str]) -> tuple[Optional[dict[str, Any]], str]:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[dws_bin, *args], capture_output=True, text=True, timeout=120
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "dws command timeout after 120 seconds"
|
||||
except FileNotFoundError:
|
||||
return None, f"dws binary not found: {dws_bin}"
|
||||
if completed.returncode != 0:
|
||||
return None, (completed.stderr or completed.stdout).strip()[:800]
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, f"dws returned non-JSON output: {exc}"
|
||||
if not isinstance(payload, dict) or payload.get("status") != "success":
|
||||
return None, f"dws business failure: {payload}"
|
||||
return payload, ""
|
||||
|
||||
|
||||
def safe_json_file(value: str) -> Any:
|
||||
root = Path(os.environ.get("OPENCLAW_WORKSPACE", os.getcwd())).resolve()
|
||||
source = Path(value).expanduser()
|
||||
source = source.resolve() if source.is_absolute() else (Path.cwd() / source).resolve()
|
||||
try:
|
||||
source.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"chart specs must be inside the workspace: {source}") from exc
|
||||
if not source.is_file() or source.stat().st_size > 1024 * 1024:
|
||||
raise ValueError("chart specs must be a readable JSON file no larger than 1 MiB")
|
||||
with source.open("r", encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def validate_specs(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list) or not value or len(value) > MAX_CHARTS:
|
||||
raise ValueError(f"chart specs must contain 1-{MAX_CHARTS} items")
|
||||
specs: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(value, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"chart spec #{index} must be an object")
|
||||
name = str(item.get("name") or "").strip()
|
||||
chart_type = str(item.get("chart_type") or "").strip().upper()
|
||||
table_id = str(item.get("table_id") or "").strip()
|
||||
measure_type = str(item.get("measure_type") or "record-count").strip()
|
||||
measure_field_id = str(item.get("measure_field_id") or "").strip()
|
||||
dimension_field_id = str(item.get("dimension_field_id") or "").strip()
|
||||
aggregation = str(item.get("aggregation") or "sum").strip().lower()
|
||||
view_id = str(item.get("view_id") or "").strip()
|
||||
if not name or len(name) > 80:
|
||||
raise ValueError(f"chart spec #{index} needs a 1-80 character name")
|
||||
if chart_type not in SUPPORTED_CHART_TYPES:
|
||||
raise ValueError(f"chart spec #{index} has unsupported chart_type: {chart_type}")
|
||||
if not RESOURCE_ID_PATTERN.fullmatch(table_id):
|
||||
raise ValueError(f"chart spec #{index} has invalid table_id")
|
||||
if measure_type not in {"record-count", "field"}:
|
||||
raise ValueError(f"chart spec #{index} has invalid measure_type")
|
||||
if measure_type == "field" and not RESOURCE_ID_PATTERN.fullmatch(measure_field_id):
|
||||
raise ValueError(f"chart spec #{index} needs measure_field_id")
|
||||
if aggregation not in SUPPORTED_AGGREGATIONS:
|
||||
raise ValueError(f"chart spec #{index} has unsupported aggregation")
|
||||
for label, resource_id in (
|
||||
("dimension_field_id", dimension_field_id), ("view_id", view_id)
|
||||
):
|
||||
if resource_id and not RESOURCE_ID_PATTERN.fullmatch(resource_id):
|
||||
raise ValueError(f"chart spec #{index} has invalid {label}")
|
||||
specs.append(
|
||||
{
|
||||
"name": name,
|
||||
"chart_type": chart_type,
|
||||
"table_id": table_id,
|
||||
"measure_type": measure_type,
|
||||
"measure_field_id": measure_field_id,
|
||||
"dimension_field_id": dimension_field_id,
|
||||
"aggregation": "average" if aggregation == "avg" else aggregation,
|
||||
"view_id": view_id,
|
||||
}
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def chart_config(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
config: dict[str, Any] = {
|
||||
"chartType": spec["chart_type"],
|
||||
"name": spec["name"],
|
||||
"sheet": spec["table_id"],
|
||||
"view": spec["view_id"] or None,
|
||||
"measureType": spec["measure_type"],
|
||||
"measure": [],
|
||||
"filter": [],
|
||||
}
|
||||
if spec["measure_type"] == "field":
|
||||
config["measure"] = [
|
||||
{
|
||||
"value": spec["measure_field_id"],
|
||||
"externalValue": [{"type": "formula", "value": spec["aggregation"]}],
|
||||
}
|
||||
]
|
||||
if spec["dimension_field_id"]:
|
||||
config["dimension"] = [
|
||||
{"value": spec["dimension_field_id"], "externalValue": []}
|
||||
]
|
||||
if spec["chart_type"] in {"AREA", "BAR", "HISTOGRAM", "LINE", "PIE"}:
|
||||
config.update({"colors": "COLOR_PALETTE_1", "legend": "top", "label": True})
|
||||
if spec["chart_type"] in {"AREA", "BAR", "HISTOGRAM", "LINE"}:
|
||||
config.update({"xAxisShow": True, "yAxisShow": True})
|
||||
if spec["chart_type"] == "PIE":
|
||||
config.update({"innerRadius": 0, "outerRadius": 60})
|
||||
return config
|
||||
|
||||
|
||||
def layout(index: int, total: int) -> dict[str, int]:
|
||||
width = 12 if total == 1 else 6 if total in {2, 3, 4} else 4
|
||||
per_row = 12 // width
|
||||
return {"x": (index % per_row) * width, "y": (index // per_row) * 5, "w": width, "h": 5}
|
||||
|
||||
|
||||
def extract_id(payload: dict[str, Any], key: str) -> str:
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||
value = data.get(key)
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def dashboard_chart_ids(payload: dict[str, Any]) -> set[str]:
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||
charts = data.get("charts") if isinstance(data.get("charts"), list) else []
|
||||
return {
|
||||
str(item.get("chartId"))
|
||||
for item in charts
|
||||
if isinstance(item, dict) and item.get("chartId")
|
||||
}
|
||||
|
||||
|
||||
def ledger_step(
|
||||
cli_path: str, status: str, params: dict[str, Any], output_ids: Optional[dict[str, str]] = None,
|
||||
error: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"cli_path": cli_path,
|
||||
"status": status,
|
||||
"params": params,
|
||||
"output_ids": output_ids or {},
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def emit(status: str, ledger: list[dict[str, Any]], **result: Any) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": LEDGER_SCHEMA_VERSION,
|
||||
"script": SCRIPT_NAME,
|
||||
"status": status,
|
||||
"result": result,
|
||||
"ledger": ledger,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("base_id", help="Target AI Table baseId")
|
||||
parser.add_argument("dashboard_name", help="Dashboard name")
|
||||
parser.add_argument(
|
||||
"--chart-specs-file", "--chart-specs", dest="chart_specs_file",
|
||||
help="Workspace-local file containing a JSON array of chart specs",
|
||||
)
|
||||
parser.add_argument("--dws", default="dws", help="dws executable")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_id = args.base_id.strip()
|
||||
dashboard_name = args.dashboard_name.strip()
|
||||
if not RESOURCE_ID_PATTERN.fullmatch(base_id) or not dashboard_name:
|
||||
parser.error("base_id and dashboard_name are required and must be valid")
|
||||
try:
|
||||
specs = validate_specs(safe_json_file(args.chart_specs_file)) if args.chart_specs_file else []
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
ledger: list[dict[str, Any]] = []
|
||||
dashboard_params = {"base-id": base_id, "name": dashboard_name, "format": "json"}
|
||||
dashboard, error = run_dws(
|
||||
args.dws,
|
||||
["aitable", "dashboard", "create", "--base-id", base_id, "--name", dashboard_name, "--format", "json"],
|
||||
)
|
||||
if not dashboard:
|
||||
ledger.append(ledger_step("aitable dashboard create", "failed", dashboard_params, error=error))
|
||||
emit("failed", ledger, error=error)
|
||||
return 1
|
||||
dashboard_id = extract_id(dashboard, "dashboardId")
|
||||
if not dashboard_id:
|
||||
error = "dashboard create returned no dashboardId"
|
||||
ledger.append(ledger_step("aitable dashboard create", "failed", dashboard_params, error=error))
|
||||
emit("failed", ledger, error=error)
|
||||
return 1
|
||||
ledger.append(
|
||||
ledger_step(
|
||||
"aitable dashboard create", "success", dashboard_params,
|
||||
{"dashboardId": dashboard_id},
|
||||
)
|
||||
)
|
||||
|
||||
chart_ids: list[str] = []
|
||||
for index, spec in enumerate(specs):
|
||||
config = chart_config(spec)
|
||||
chart_layout = layout(index, len(specs))
|
||||
params = {
|
||||
"base-id": base_id,
|
||||
"dashboard-id": dashboard_id,
|
||||
"config": config,
|
||||
"layout": chart_layout,
|
||||
"format": "json",
|
||||
}
|
||||
chart, error = run_dws(
|
||||
args.dws,
|
||||
[
|
||||
"aitable", "chart", "create", "--base-id", base_id,
|
||||
"--dashboard-id", dashboard_id,
|
||||
"--config", json.dumps(config, ensure_ascii=False, separators=(",", ":")),
|
||||
"--layout", json.dumps(chart_layout, separators=(",", ":")),
|
||||
"--format", "json",
|
||||
],
|
||||
)
|
||||
if not chart:
|
||||
ledger.append(ledger_step("aitable chart create", "failed", params, error=error))
|
||||
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
|
||||
return 1
|
||||
chart_id = extract_id(chart, "chartId")
|
||||
if not chart_id:
|
||||
error = "chart create returned no chartId"
|
||||
ledger.append(ledger_step("aitable chart create", "failed", params, error=error))
|
||||
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
|
||||
return 1
|
||||
chart_ids.append(chart_id)
|
||||
ledger.append(
|
||||
ledger_step("aitable chart create", "success", params, {"chartId": chart_id})
|
||||
)
|
||||
|
||||
get_params = {"base-id": base_id, "dashboard-id": dashboard_id, "format": "json"}
|
||||
verified, error = run_dws(
|
||||
args.dws,
|
||||
["aitable", "dashboard", "get", "--base-id", base_id, "--dashboard-id", dashboard_id, "--format", "json"],
|
||||
)
|
||||
if not verified:
|
||||
ledger.append(ledger_step("aitable dashboard get", "failed", get_params, error=error))
|
||||
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
|
||||
return 1
|
||||
missing_chart_ids = set(chart_ids) - dashboard_chart_ids(verified)
|
||||
if missing_chart_ids:
|
||||
error = "dashboard verification missing chartIds: " + ",".join(sorted(missing_chart_ids))
|
||||
ledger.append(ledger_step("aitable dashboard get", "failed", get_params, error=error))
|
||||
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
|
||||
return 1
|
||||
ledger.append(
|
||||
ledger_step("aitable dashboard get", "success", get_params, {"dashboardId": dashboard_id})
|
||||
)
|
||||
emit("success", ledger, dashboardId=dashboard_id, chartIds=chart_ids)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -25,6 +25,7 @@ BULK_FIELDS = load_module("aitable_bulk_fields", "bulk_add_fields.py")
|
||||
UPLOAD_ATTACHMENT = load_module("aitable_upload_attachment", "upload_attachment.py")
|
||||
EXPORT_TASK = load_module("aitable_export_task", "aitable_export_via_task.py")
|
||||
IMPORT_TASK = load_module("aitable_import_task", "aitable_import_via_task.py")
|
||||
AITABLE_OPS = load_module("aitable_ops", "aitable_ops.py")
|
||||
|
||||
|
||||
class AITableSkillScriptsTest(unittest.TestCase):
|
||||
@@ -59,6 +60,81 @@ class AITableSkillScriptsTest(unittest.TestCase):
|
||||
self.assertEqual(records[0]["cells"]["fldPhone01"], "00123")
|
||||
self.assertEqual(records[0]["cells"]["fldBool01"], "true")
|
||||
|
||||
def test_unified_ops_dispatches_without_cross_operation_arguments(self):
|
||||
cases = [
|
||||
(
|
||||
["dashboard", "base12345678", "概览", "--chart-specs-file", "charts.json"],
|
||||
"create_dashboard_chart.py",
|
||||
["base12345678", "概览", "--chart-specs-file", "charts.json"],
|
||||
),
|
||||
(
|
||||
["import-new", "base12345678", "data.csv"],
|
||||
"aitable_import_via_task.py",
|
||||
["base12345678", "data.csv"],
|
||||
),
|
||||
(
|
||||
["import-records", "base12345678", "table1234567", "data.json"],
|
||||
"import_records.py",
|
||||
["base12345678", "table1234567", "data.json", "100"],
|
||||
),
|
||||
(
|
||||
["export", "base12345678", "--scope", "view", "--table-id", "table1234567", "--view-id", "view12345678"],
|
||||
"aitable_export_via_task.py",
|
||||
["base12345678", "--scope", "view", "--table-id", "table1234567", "--view-id", "view12345678"],
|
||||
),
|
||||
(
|
||||
["add-fields", "base12345678", "table1234567", "fields.json"],
|
||||
"bulk_add_fields.py",
|
||||
["base12345678", "table1234567", "fields.json"],
|
||||
),
|
||||
(
|
||||
["upload-attachment", "base12345678", "report.pdf"],
|
||||
"upload_attachment.py",
|
||||
["base12345678", "report.pdf"],
|
||||
),
|
||||
]
|
||||
parser = AITABLE_OPS.parser()
|
||||
for argv, script, expected_tail in cases:
|
||||
with self.subTest(operation=argv[0]):
|
||||
command = AITABLE_OPS.command_for(parser.parse_args(argv))
|
||||
self.assertEqual(Path(command[1]).name, script)
|
||||
self.assertEqual(command[2:], expected_tail)
|
||||
|
||||
def test_unified_ops_normalizes_delegated_trusted_ledger(self):
|
||||
payload = {
|
||||
"schema_version": "dws-skill-script-ledger/v1",
|
||||
"script": "create_dashboard_chart.py",
|
||||
"status": "success",
|
||||
"ledger": [],
|
||||
}
|
||||
normalized = json.loads(AITABLE_OPS.normalize_output(json.dumps(payload)))
|
||||
self.assertEqual(normalized["script"], "aitable_ops.py")
|
||||
self.assertEqual(normalized["implementation_script"], "create_dashboard_chart.py")
|
||||
|
||||
def test_unified_ops_emits_export_task_ledger_with_nonempty_file(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
exported = Path(raw) / "result.xlsx"
|
||||
exported.write_bytes(b"excel")
|
||||
args = AITABLE_OPS.parser().parse_args([
|
||||
"export", "base12345678", "--scope", "table",
|
||||
"--table-id", "tbl1234", "--output", str(exported),
|
||||
])
|
||||
payload = {
|
||||
"status": "success",
|
||||
"taskId": "task12345678",
|
||||
"downloadUrl": "https://example.invalid/result.xlsx",
|
||||
"polledTimes": 2,
|
||||
"savedPath": str(exported),
|
||||
}
|
||||
normalized = json.loads(AITABLE_OPS.normalize_output(json.dumps(payload), args))
|
||||
self.assertEqual(normalized["script"], "aitable_ops.py")
|
||||
self.assertEqual(normalized["ledger"][0]["cli_path"], "aitable export data")
|
||||
self.assertEqual(normalized["ledger"][0]["output_ids"]["polledTimes"], 2)
|
||||
self.assertEqual(normalized["ledger"][0]["output_ids"]["fileSize"], 5)
|
||||
|
||||
def test_export_accepts_runtime_seven_character_table_id(self):
|
||||
self.assertTrue(EXPORT_TASK.validate_resource_id("VS9pVmc"))
|
||||
|
||||
def test_import_records_checks_ids_and_readback(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
@@ -175,7 +251,7 @@ elif args[:3] == ['aitable', 'field', 'get']:
|
||||
self.assertEqual(payload["verifiedCount"], 1)
|
||||
self.assertEqual(payload["ledger"][1]["error"], "duplicate")
|
||||
|
||||
def test_upload_helpers_reject_plain_http_urls(self):
|
||||
def test_upload_helpers_use_secure_urls(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
file_path = Path(raw) / "file.bin"
|
||||
file_path.write_bytes(b"x")
|
||||
@@ -184,9 +260,13 @@ elif args[:3] == ['aitable', 'field', 'get']:
|
||||
"http://example.com/upload", file_path, "application/octet-stream"
|
||||
)
|
||||
)
|
||||
ok, error = IMPORT_TASK.put_file("http://example.com/upload", file_path)
|
||||
self.assertEqual(
|
||||
IMPORT_TASK.normalize_upload_url("http://example.com/upload?signature=x"),
|
||||
"https://example.com/upload?signature=x",
|
||||
)
|
||||
ok, error = IMPORT_TASK.put_file("ftp://example.com/upload", file_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("HTTPS", error)
|
||||
self.assertIn("HTTP(S)", error)
|
||||
|
||||
def test_export_helpers_reject_http_and_existing_output(self):
|
||||
with self.assertRaises(ValueError):
|
||||
|
||||
Reference in New Issue
Block a user