Skill: balonegit/appflowy-skill (version 0.1.0)

--- name: appflowy-api description: "AppFlowy Cloud/GoTrue API 的认证与调用流程（获取 token、workspace/文档/数据库/搜索等）。在本仓库用 Python 编写或调试 AppFlowy API 客户端、脚本、自动化或排查接口问题时使用。" --- # AppFlowy API ## 概述 本 skill 用于自托管 AppFlowy 环境的 API 调用与自动化，覆盖登录鉴权、文档/视图/数据库操作、搜索、协作数据（collab）等常见场景。默认**不读取 `.env`**，仅在显式传入 `--env <path>` 时读取。 本 skill 当前适配 AppFlowy Cloud `0.12.3`。`doctor.py` 会通过 `/api/health` 检测版本并在不匹配时给出警告。 ## 快速开始 1. 准备 base URL 与 GoTrue URL（可选 `--env <path>` 读取 `.env`）。 2. 使用账号密码获取 `access_token`。 3. 携带必要请求头调用 AppFlowy API。 ```bash # 获取 token curl -sS -X POST "http://10.60.0.189/gotrue/token?grant_type=password" \ -H "Content-Type: application/json" \ -d '{"email":"<email>","password":"<password>"}' ``` ```bash # 调用 API（示例：搜索） curl -sS "http://10.60.0.189/api/search/<workspace_id>?query=test" \ -H "Authorization: Bearer <access_token>" \ -H "client-version: 0.12.3" \ -H "client-timestamp: 1700000000000" \ -H "device-id: <uuid>" ``` ## 统一入口（推荐） 统一入口脚本用于封装命令风格，适合自动化与外部集成： ```bash python skills/appflowy-api/scripts/appflowy_skill.py list python skills/appflowy-api/scripts/appflowy_skill.py help apply-grid ``` ## 配置优先级 解析优先级（从高到低）： 1. 命令行参数：`--base-url`、`--gotrue-url`、`--client-version`、`--device-id` 2. 配置文件：`--config <path>`（JSON，示例见 `skills/appflowy-api/references/config.example.json`） 3. 环境变量：`APPFLOWY_BASE_URL`、`API_EXTERNAL_URL`、`APPFLOWY_GOTRUE_BASE_URL` 4. `.env` 文件：仅在传入 `--env <path>` 时读取 ## 常用脚本 ```bash # 获取 token python skills/appflowy-api/scripts/get_token.py --email <email> --password <password> ``` ```bash # 自检（不会自动读取 .env） python skills/appflowy-api/scripts/doctor.py --config skills/appflowy-api/references/config.example.json --email <email> --password <password> ``` ```bash # 生成“用户管理系统”文档（UTF-8 模板，表格顺序为正序） python skills/appflowy-api/scripts/create_user_management_doc.py --config skills/appflowy-api/references/config.example.json --email <email> --password <password> ``` ```bash # 就地修正文档（通用模板脚本） python skills/appflowy-api/scripts/update_user_management_doc.py --config skills/appflowy-api/references/config.example.json --email <email> --password <password> --workspace-id <workspace_id> --view-id <view_id> ``` ```bash # 通用模板：按模板更新 Grid（默认就地修改） python skills/appflowy-api/scripts/apply_grid_template.py --config skills/appflowy-api/references/config.example.json --email <email> --password <password> --workspace-id <workspace_id> --view-id <view_id> --template-file <template.json> ``` ## 子内容规则（子任务 / 子项 / 子 Grid） 1. `子任务`（Checklist/Todo 列）：适用于**简单描述**的子内容，不需要额外字段。 2. `子项`（Relation 列）：当子内容与父级**字段结构一致**时，通过关联行管理。 3. `子 Grid`：当子内容需要**独立字段结构**时，新建 Grid 并在父级引用或说明。 ## Grid 默认空行处理 新建 Grid 时可能自动生成 3 条空行。脚本在写入数据前会清理默认空行，避免空行混入真实计划。 ## 必需请求头 所有 AppFlowy API 请求均需携带： 1. `Authorization: Bearer <access_token>` 2. `client-version: <AppFlowy 版本>`（建议与部署版本一致） 3. `client-timestamp: <Unix 毫秒>` 4. `device-id: <UUID>` ## 错误处理与排障 1. HTTP 200 但响应体包含 `success=false` 或 `error` 视为业务失败。 2. 控制台提示无法连接时，优先检查宿主机 `80/443` 可达性与防火墙规则。 3. 容器间调用优先使用内部地址（如 `http://gotrue:9999`、`http://appflowy_cloud:8000`）。 ## 资源 1. `skills/appflowy-api/scripts/`：Python/Node 脚本与通用库。 2. `skills/appflowy-api/references/`：API 参考与模板文件。 3. [REDACTED_SECRET]：UTF-8 模板，避免乱码与字段顺序问题。 4. `skills/appflowy-api/examples/`：示例命令与用法。

path: README.md
content: # AppFlowy API Skill

用于自托管 AppFlowy 的 API 调用与自动化：登录获取 token、文档/视图/数据库操作、搜索、协作数据更新等。

## 入口方式
本技能提供两类入口：
1. **单脚本入口**：直接运行某个脚本，如 `python scripts/doctor.py ...`  
   - 优点：脚本即文档，参数最清晰  
   - 适合：脚本级调试、精细控制
2. **统一入口**：`python scripts/appflowy_skill.py <command> ...`  
   - 优点：命令风格统一、便于上层工具封装  
   - 适合：自动化流程、对外集成

查看统一入口可用命令：
```bash
python skills/appflowy-api/scripts/appflowy_skill.py list
```

查看某个命令帮助：
```bash
python skills/appflowy-api/scripts/appflowy_skill.py help apply-grid
```

## 结构
- `SKILL.md`：技能说明（供 Codex/Claude/OpenClaw 读取）
- `scripts/`：可复用脚本与通用库
- `references/`：API 参考与模板文件（UTF-8）
- `examples/`：示例命令与用法

## 快速示例
```bash
# 自检
python skills/appflowy-api/scripts/appflowy_skill.py doctor --config skills/appflowy-api/references/config.example.json --email <email> --password <password>

# 应用 Grid 模板（就地修改）
python skills/appflowy-api/scripts/appflowy_skill.py apply-grid --config skills/appflowy-api/references/config.example.json --email <email> --password <password> --workspace-id <workspace_id> --view-id <view_id> --template-file skills/appflowy-api/references/templates/fitness_plan.example.json
```


path: agents/openai.yaml
content: interface:
  display_name: "AppFlowy API"
  short_description: "AppFlowy API 认证与调用（workspace/文档/数据库/搜索）"
  default_prompt: "使用 $appflowy-api 获取 GoTrue token 并调用 AppFlowy Cloud API。"


path: examples/README.md
content: # 示例

以下示例使用仓库内置的 UTF-8 模板，适合演示与测试。

## 健身计划文档（Grid）
```bash
python skills/appflowy-api/scripts/appflowy_skill.py apply-grid \
  --config skills/appflowy-api/references/config.example.json \
  --email <email> --password <password> \
  --workspace-id <workspace_id> --view-id <view_id> \
  --template-file skills/appflowy-api/references/templates/fitness_plan.example.json
```

## 用户管理系统文档（就地修正 + Grid）
```bash
python skills/appflowy-api/scripts/appflowy_skill.py update-user-management-doc \
  --config skills/appflowy-api/references/config.example.json \
  --email <email> --password <password> \
  --workspace-id <workspace_id> --view-id <view_id>
```

## 通用 Grid 模板
```bash
python skills/appflowy-api/scripts/appflowy_skill.py apply-grid \
  --config skills/appflowy-api/references/config.example.json \
  --email <email> --password <password> \
  --workspace-id <workspace_id> --view-id <view_id> \
  --template-file skills/appflowy-api/references/templates/grid_plan.example.json
```


path: references/appflowy_api_reference.md
content: # AppFlowy API 参考（自托管）

## 认证与请求头
- 获取 token（password grant）  
  `POST /gotrue/token?grant_type=password`  
  Body: `{"email":"...","password":"..."}`
- AppFlowy API 必需请求头  
  `Authorization: Bearer <access_token>`  
  `client-version: <部署版本>`  
  `client-timestamp: <Unix 毫秒>`  
  `device-id: <UUID>`

## 常用端点
### Workspace
- `GET /api/workspace`

### 文档 / 视图
- `POST /api/workspace/{workspace_id}/page-view`
- `POST /api/workspace/{workspace_id}/page-view/{view_id}/append-block`
- `POST /api/workspace/{workspace_id}/page-view/{view_id}/database-view`

### 数据库
- `GET /api/workspace/{workspace_id}/database`
- `POST /api/workspace/{workspace_id}/database/{database_id}/fields`
- `POST /api/workspace/{workspace_id}/database/{database_id}/row`
- `PUT /api/workspace/{workspace_id}/database/{database_id}/row`
- `GET /api/workspace/{workspace_id}/database/{database_id}/row/detail?ids=...`

### 搜索
- `GET /api/search/{workspace_id}?query=...`

### 协作（Collab）
- `GET /api/workspace/v1/{workspace_id}/collab/{object_id}?collab_type=0`
- `GET /api/workspace/v1/{workspace_id}/collab/{object_id}/json?collab_type=0`
- `POST /api/workspace/v1/{workspace_id}/collab/{object_id}/web-update`

## 请求示例
### 创建页面
```json
{
  "name": "示例文档",
  "view_type": 0,
  "parent_view_id": "<parent_view_id>",
  "parent_view_type": 0
}
```

### 追加块（Quill Delta）
```json
{
  "type": "paragraph",
  "data": {
    "delta": [
      { "insert": "Hello AppFlowy\n" }
    ]
  }
}
```

### 添加字段
```json
{
  "name": "标题",
  "field_type": 0
}
```

### 写入/更新行（cells）
```json
{
  "pre_hash": "example:row-key",
  "cells": {
    "Name": "示例",
    "状态": { "name": "未开始" }
  }
}
```

## 错误处理
- HTTP 200 但响应体包含 `success=false` 或 `error` 视为业务失败。
- 控制台提示无法连接时，优先检查宿主机 `80/443` 端口与防火墙规则。
- 容器间调用优先使用内部地址（如 `http://gotrue:9999`、`http://appflowy_cloud:8000`）。

## 说明
- 模板文件与脚本输出请使用 UTF-8，避免中文乱码。
- Grid 默认可能生成 3 条空行，建议在写入真实数据前清理。


path: references/config.example.json
content: base_url: http://10.60.0.189
gotrue_url: http://10.60.0.189/gotrue
client_version: 0.12.3
expected_appflowy_version: 0.12.3
device_id: 00000000-0000-0000-0000-000000000000

path: references/payloads/add_db_field.json
content: name: 标题
field_type: 0

path: references/payloads/append_block.json
content: {
  "blocks": [
    {
      "type": "paragraph",
      "data": {
        "delta": [
          { "insert": "Hello AppFlowy\n" }
        ]
      }
    }
  ]
}


path: references/payloads/create_page_view.json
content: parent_view_id: <parent_view_id>
layout: 0
name: 示例文档

path: references/payloads/upsert_row.json
content: {
  "cells": {
    "<field_id_or_name>": "示例值"
  }
}


path: references/templates/fitness_doc_blocks.json
content: {
  "blocks": [
    {
      "type": "heading",
      "data": { "level": 2, "delta": [{ "insert": "任务目的" }] }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": [
          {
            "insert": "建立可执行的 4-6 周个人健身计划，提升基础体能与力量，同时形成稳定训练习惯。\n"
          }
        ]
      }
    },
    {
      "type": "heading",
      "data": { "level": 2, "delta": [{ "insert": "主要内容" }] }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": [
          {
            "insert": "包含每周训练安排（有氧+力量+恢复）、阶段目标、子任务清单与复盘机制。\n"
          }
        ]
      }
    },
    {
      "type": "heading",
      "data": { "level": 2, "delta": [{ "insert": "约束条件" }] }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": [
          {
            "insert": "每周训练 4-5 天，单次 45-60 分钟；避免连续高强度；保证恢复与睡眠。\n"
          }
        ]
      }
    }
  ]
}


path: references/templates/fitness_plan.example.json
content: {
  "grid": {
    "name": "FitnessPlanGrid",
    "heading": "详细训练计划表格（Grid）"
  },
  "rules": {
    "clean_default_rows": true,
    "max_default_rows": 3
  },
  "fields": [
    { "name": "负责人", "field_type": 0 },
    { "name": "说明", "field_type": 0 },
    { "name": "工时(人天)", "field_type": 1 },
    { "name": "开始日期", "field_type": 2 },
    {
      "name": "阶段",
      "field_type": 3,
      "type_option_data": {
        "content": "{\"options\": [{\"id\": \"phase-base\", \"name\": \"基础期\", \"color\": \"Yellow\"}, {\"id\": \"phase-strength\", \"name\": \"力量期\", \"color\": \"Blue\"}, {\"id\": \"phase-cut\", \"name\": \"减脂期\", \"color\": \"Orange\"}, {\"id\": \"phase-maintain\", \"name\": \"巩固期\", \"color\": \"Green\"}], \"disable_color\": false}"
      }
    },
    {
      "name": "状态",
      "field_type": 3,
      "type_option_data": {
        "content": "{\"options\": [{\"id\": \"status-todo\", \"name\": \"未开始\", \"color\": \"Purple\"}, {\"id\": \"status-doing\", \"name\": \"进行中\", \"color\": \"Blue\"}, {\"id\": \"status-done\", \"name\": \"已完成\", \"color\": \"Green\"}], \"disable_color\": false}"
      }
    },
    { "name": "子任务", "field_type": 7 },
    { "name": "依赖", "field_type": 10, "type_option_data": { "database_id": "<db_id_placeholder>" } },
    { "name": "子项", "field_type": 10, "type_option_data": { "database_id": "<db_id_placeholder>" } }
  ],
  "rows": [
    {
      "key": "baseline",
      "cells": {
        "Name": "体测与目标设定",
        "负责人": "本人",
        "说明": "记录体重、体脂、围度与目标体重",
        "工时(人天)": 1,
        "开始日期": "2026-03-01T09:00:00Z",
        "阶段": { "name": "基础期" },
        "状态": { "name": "已完成" },
        "子任务": {
          "options": [
            { "id": "ck-1-1", "name": "体重体脂记录", "color": "Pink" },
            { "id": "ck-1-2", "name": "目标设定", "color": "Orange" },
            { "id": "ck-1-3", "name": "训练安排草案", "color": "Green" }
          ],
          "selected_option_ids": ["ck-1-1"]
        }
      }
    },
    {
      "key": "cardio-base",
      "cells": {
        "Name": "有氧基础训练",
        "负责人": "本人",
        "说明": "每周 3 次 30-40 分钟中低强度",
        "工时(人天)": 3,
        "开始日期": "2026-03-02T09:00:00Z",
        "阶段": { "name": "基础期" },
        "状态": { "name": "进行中" },
        "子任务": {
          "options": [
            { "id": "ck-2-1", "name": "慢跑 30 分钟", "color": "Pink" },
            { "id": "ck-2-2", "name": "动感单车 30 分钟", "color": "Orange" },
            { "id": "ck-2-3", "name": "拉伸 10 分钟", "color": "Green" }
          ],
          "selected_option_ids": ["ck-2-1"]
        }
      },
      "depends_on": ["baseline"]
    },
    {
      "key": "strength-a",
      "cells": {
        "Name": "力量训练 A（下肢/核心）",
        "负责人": "本人",
        "说明": "深蹲、硬拉、卷腹与平板支撑",
        "工时(人天)": 2,
        "开始日期": "2026-03-03T09:00:00Z",
        "阶段": { "name": "力量期" },
        "状态": { "name": "未开始" },
        "子任务": {
          "options": [
            { "id": "ck-3-1", "name": "深蹲 4x10", "color": "Pink" },
            { "id": "ck-3-2", "name": "硬拉 4x12", "color": "Orange" },
            { "id": "ck-3-3", "name": "平板支撑 3x45 秒", "color": "Green" }
          ],
          "selected_option_ids": ["ck-3-1"]
        }
      },
      "depends_on": ["baseline"]
    },
    {
      "key": "strength-b",
      "cells": {
        "Name": "力量训练 B（上肢/背部）",
        "负责人": "本人",
        "说明": "俯卧撑、哑铃划船、肩推",
        "工时(人天)": 2,
        "开始日期": "2026-03-05T09:00:00Z",
        "阶段": { "name": "力量期" },
        "状态": { "name": "未开始" },
        "子任务": {
          "options": [
            { "id": "ck-4-1", "name": "俯卧撑 4x12", "color": "Pink" },
            { "id": "ck-4-2", "name": "哑铃划船 4x10", "color": "Orange" },
            { "id": "ck-4-3", "name": "肩推 3x10", "color": "Green" }
          ],
          "selected_option_ids": ["ck-4-1"]
        }
      },
      "depends_on": ["baseline"]
    },
    {
      "key": "mobility",
      "cells": {
        "Name": "恢复与拉伸",
        "负责人": "本人",
        "说明": "泡沫轴 + 动态拉伸 + 睡眠记录",
        "工时(人天)": 1,
        "开始日期": "2026-03-06T09:00:00Z",
        "阶段": { "name": "巩固期" },
        "状态": { "name": "进行中" },
        "子任务": {
          "options": [
            { "id": "ck-5-1", "name": "泡沫轴 10 分钟", "color": "Pink" },
            { "id": "ck-5-2", "name": "动态拉伸 10 分钟", "color": "Orange" },
            { "id": "ck-5-3", "name": "睡眠记录", "color": "Green" }
          ],
          "selected_option_ids": ["ck-5-1"]
        }
      }
    },
    {
      "key": "weekly-review",
      "cells": {
        "Name": "每周复盘与调整",
        "负责人": "本人",
        "说明": "根据训练反馈调整负荷与节奏",
        "工时(人天)": 1,
        "开始日期": "2026-03-07T09:00:00Z",
        "阶段": { "name": "巩固期" },
        "状态": { "name": "未开始" },
        "子任务": {
          "options": [
            { "id": "ck-6-1", "name": "训练完成率统计", "color": "Pink" },
            { "id": "ck-6-2", "name": "疲劳评估", "color": "Orange" },
            { "id": "ck-6-3", "name": "下周计划调整", "color": "Green" }
          ],
          "selected_option_ids": ["ck-6-1"]
        }
      },
      "depends_on": ["cardio-base", "strength-a", "strength-b"]
    },
    {
      "key": "milestone-month1",
      "cells": {
        "Name": "第 1 月目标达成",
        "负责人": "本人",
        "说明": "完成 12 次训练并维持饮食记录",
        "工时(人天)": 1,
        "开始日期": "2026-03-31T09:00:00Z",
        "阶段": { "name": "巩固期" },
        "状态": { "name": "未开始" },
        "子任务": {
          "options": [
            { "id": "ck-7-1", "name": "训练打卡汇总", "color": "Pink" },
            { "id": "ck-7-2", "name": "体测复盘", "color": "Orange" },
            { "id": "ck-7-3", "name": "下阶段目标", "color": "Green" }
          ],
          "selected_option_ids": ["ck-7-1"]
        }
      },
      "children": ["cardio-base", "strength-a", "strength-b", "mobility", "weekly-review"]
    }
  ]
}


path: references/templates/grid_plan.example.json
content: {
  "grid": {
    "name": "PlanGrid",
    "heading": "详细工作计划表格（Grid）"
  },
  "rules": {
    "clean_default_rows": true,
    "max_default_rows": 3
  },
  "fields": [
    { "name": "负责人", "field_type": 0 },
    { "name": "说明", "field_type": 0 },
    { "name": "工时(人天)", "field_type": 1 },
    { "name": "开始日期", "field_type": 2 },
    {
      "name": "阶段",
      "field_type": 3,
      "type_option_data": {
        "content": "{\"options\": [{\"id\": \"phase-plan\", \"name\": \"规划\", \"color\": \"Yellow\"}, {\"id\": \"phase-dev\", \"name\": \"开发\", \"color\": \"Blue\"}], \"disable_color\": false}"
      }
    },
    {
      "name": "状态",
      "field_type": 3,
      "type_option_data": {
        "content": "{\"options\": [{\"id\": \"status-todo\", \"name\": \"未开始\", \"color\": \"Purple\"}, {\"id\": \"status-doing\", \"name\": \"进行中\", \"color\": \"Blue\"}, {\"id\": \"status-done\", \"name\": \"已完成\", \"color\": \"Green\"}], \"disable_color\": false}"
      }
    },
    { "name": "子任务", "field_type": 7 },
    { "name": "依赖", "field_type": 10, "type_option_data": { "database_id": "<db_id_placeholder>" } },
    { "name": "子项", "field_type": 10, "type_option_data": { "database_id": "<db_id_placeholder>" } }
  ],
  "rows": [
    {
      "key": "req-clarify",
      "cells": {
        "Name": "需求澄清与范围定义",
        "负责人": "产品经理",
        "说明": "输出 PRD 与风险清单",
        "工时(人天)": 3,
        "开始日期": "2026-03-01T09:00:00Z",
        "阶段": { "name": "规划" },
        "状态": { "name": "已完成" },
        "子任务": {
          "options": [
            { "id": "ck-1-1", "name": "访谈干系人", "color": "Pink" },
            { "id": "ck-1-2", "name": "竞品分析", "color": "Orange" },
            { "id": "ck-1-3", "name": "范围确认", "color": "Green" }
          ],
          "selected_option_ids": ["ck-1-1"]
        }
      }
    },
    {
      "key": "auth-api",
      "cells": {
        "Name": "认证与登录接口",
        "负责人": "后端负责人",
        "说明": "实现登录、刷新、登出流程",
        "工时(人天)": 4,
        "开始日期": "2026-03-10T09:00:00Z",
        "阶段": { "name": "开发" },
        "状态": { "name": "进行中" },
        "子任务": {
          "options": [
            { "id": "ck-2-1", "name": "登录接口", "color": "Pink" },
            { "id": "ck-2-2", "name": "Token 刷新", "color": "Orange" },
            { "id": "ck-2-3", "name": "安全审计", "color": "Green" }
          ],
          "selected_option_ids": ["ck-2-1"]
        }
      },
      "depends_on": ["req-clarify"]
    },
    {
      "key": "milestone-dev",
      "cells": {
        "Name": "开发里程碑",
        "负责人": "项目负责人",
        "说明": "核心功能开发完成",
        "工时(人天)": 1,
        "开始日期": "2026-03-26T09:00:00Z",
        "阶段": { "name": "开发" },
        "状态": { "name": "进行中" },
        "子任务": {
          "options": [
            { "id": "ck-3-1", "name": "后端完成", "color": "Pink" },
            { "id": "ck-3-2", "name": "前端完成", "color": "Orange" },
            { "id": "ck-3-3", "name": "接口联调", "color": "Green" }
          ],
          "selected_option_ids": ["ck-3-1"]
        }
      },
      "children": ["auth-api"]
    }
  ]
}


path: references/templates/user_management_doc.json
content: {
  "blocks": [
    {
      "type": "heading",
      "data": {
        "level": 1,
        "delta": [
          {
            "insert": "用户管理系统开发文档（测试）"
          }
        ]
      }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": []
      }
    },
    {
      "type": "heading",
      "data": {
        "level": 2,
        "delta": [
          {
            "insert": "任务目的"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "建立通用的用户管理基础能力，用于账号全生命周期管理与权限控制。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "支撑后续业务系统快速接入与统一身份治理。"
          }
        ]
      }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": []
      }
    },
    {
      "type": "heading",
      "data": {
        "level": 2,
        "delta": [
          {
            "insert": "主要功能"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "用户信息管理：创建、编辑、停用、删除、查询。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "认证与安全：登录、密码策略、重置、锁定、审计。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "授权与角色：角色分配、权限矩阵、最小权限原则。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "组织与分组：部门/团队/租户结构维护。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "日志与合规：操作日志、导出、异常告警。"
          }
        ]
      }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": []
      }
    },
    {
      "type": "heading",
      "data": {
        "level": 2,
        "delta": [
          {
            "insert": "性能约束"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "可用性：核心接口可用性 ≥ 99.9%。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "延迟：登录/查询 P95 < 300ms（视部署环境调整）。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "吞吐：支持至少 200 RPS 的并发访问。"
          }
        ]
      }
    },
    {
      "type": "bulleted_list",
      "data": {
        "delta": [
          {
            "insert": "扩展：支持水平扩展与缓存加速。"
          }
        ]
      }
    },
    {
      "type": "paragraph",
      "data": {
        "delta": []
      }
    },
    {
      "type": "heading",
      "data": {
        "level": 2,
        "delta": [
          {
            "insert": "详细工作内容及时间计划表"
          }
        ]
      }
    },
    {
      "type": "simple_table",
      "children": [
        {
          "type": "simple_table_row",
          "children": [
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "第 1 周"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "需求梳理与技术方案；角色/权限模型设计；接口清单定义"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "需求说明、模型草图、接口清单"
                      }
                    ]
                  }
                }
              ]
            }
          ]
        },
        {
          "type": "simple_table_row",
          "children": [
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "第 2 周"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "核心功能开发：用户 CRUD、认证、基础授权"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "核心接口与管理端雏形"
                      }
                    ]
                  }
                }
              ]
            }
          ]
        },
        {
          "type": "simple_table_row",
          "children": [
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "第 3 周"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "组织与权限完善；审计日志与告警"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "组织/权限能力与审计日志"
                      }
                    ]
                  }
                }
              ]
            }
          ]
        },
        {
          "type": "simple_table_row",
          "children": [
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "第 4 周"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "测试与上线准备：性能/安全/回归"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "测试报告与上线文档"
                      }
                    ]
                  }
                }
              ]
            }
          ]
        },
        {
          "type": "simple_table_row",
          "children": [
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "阶段/周次"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "工作内容"
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "simple_table_cell",
              "children": [
                {
                  "type": "paragraph",
                  "data": {
                    "delta": [
                      {
                        "insert": "产出"
                      }
                    ]
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

path: scripts/_common.py
content: import json
import os
from pathlib import Path
from typing import Any, Dict, Optional

from appflowy_client import AppFlowyClient, AppFlowyError, load_env


def load_config(path: Optional[str]) -> Dict[str, Any]:
    if not path:
        return {}
    cfg_path = Path(path)
    if not cfg_path.exists():
        raise AppFlowyError(f"Config file not found: {cfg_path}")
    return json.loads(cfg_path.read_text(encoding="utf-8"))


def _pick(*values):
    for value in values:
        if value:
            return value
    return None


def build_client(args) -> AppFlowyClient:
    cfg = load_config(getattr(args, "config", None))
    env_path = getattr(args, "env", None)
    env_file = {}
    if env_path:
        env_file = load_env(env_path)
    base_url = _pick(
        getattr(args, "base_url", None),
        cfg.get("base_url") or cfg.get("APPFLOWY_BASE_URL"),
        os.environ.get("APPFLOWY_BASE_URL"),
        env_file.get("APPFLOWY_BASE_URL") if env_file else None,
    )
    gotrue_url = _pick(
        getattr(args, "gotrue_url", None),
        cfg.get("gotrue_url") or cfg.get("API_EXTERNAL_URL") or cfg.get("APPFLOWY_GOTRUE_BASE_URL"),
        os.environ.get("API_EXTERNAL_URL"),
        os.environ.get("APPFLOWY_GOTRUE_BASE_URL"),
        env_file.get("API_EXTERNAL_URL") if env_file else None,
        env_file.get("APPFLOWY_GOTRUE_BASE_URL") if env_file else None,
    )
    client_version = _pick(
        getattr(args, "client_version", None),
        cfg.get("client_version") or cfg.get("APPFLOWY_CLIENT_VERSION"),
        os.environ.get("APPFLOWY_CLIENT_VERSION"),
        env_file.get("APPFLOWY_CLIENT_VERSION") if env_file else None,
    )
    device_id = _pick(
        getattr(args, "device_id", None),
        cfg.get("device_id") or cfg.get("APPFLOWY_DEVICE_ID"),
        os.environ.get("APPFLOWY_DEVICE_ID"),
        env_file.get("APPFLOWY_DEVICE_ID") if env_file else None,
    )
    return AppFlowyClient.from_env(
        env_path=None,
        base_url=base_url,
        gotrue_url=gotrue_url,
        client_version=client_version,
        device_id=device_id,
    )


def resolve_token(args, client: AppFlowyClient) -> str:
    if args.[REDACTED_SECRET] args.token
    if args.email and args.password:
        resp = client.login_password(args.email, args.password)
        [REDACTED_SECRET]"access_token") or resp.get("token")
        if not token:
            raise AppFlowyError("No access_token in GoTrue response", response=resp)
        return token
    raise AppFlowyError("Missing token or email/password for authentication")


def load_json_payload(payload: Optional[str], payload_file: Optional[str]) -> Dict[str, Any]:
    if payload and payload_file:
        raise AppFlowyError("Provide only one of --payload or --payload-file")
    if payload_file:
        text = Path(payload_file).read_text(encoding="utf-8")
        return json.loads(text)
    if payload:
        return json.loads(payload)
    raise AppFlowyError("Missing JSON payload")


def print_json(data: Any) -> None:
    print(json.dumps(data, indent=2, ensure_ascii=True))


path: scripts/add_db_field.py
content: import argparse

from _common import build_client, load_json_payload, print_json, resolve_token


def main() -> int:
    parser = argparse.ArgumentParser(description="Add a field to a database.")
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--database-id", required=True)
    parser.add_argument("--payload", default=None, help="JSON string payload")
    parser.add_argument("--payload-file", default=None, help="Path to JSON payload file")
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    payload = load_json_payload(args.payload, args.payload_file)
    result = client.add_database_field(token, args.workspace_id, args.database_id, payload)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/append_block.py
content: import argparse

from _common import build_client, load_json_payload, print_json, resolve_token


def main() -> int:
    parser = argparse.ArgumentParser(description="Append block to a page view.")
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--view-id", required=True)
    parser.add_argument("--payload", default=None, help="JSON string payload")
    parser.add_argument("--payload-file", default=None, help="Path to JSON payload file")
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    payload = load_json_payload(args.payload, args.payload_file)
    result = client.append_block(token, args.workspace_id, args.view_id, payload)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/appflowy_client.py
content: from __future__ import annotations

import json
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


class AppFlowyError(RuntimeError):
    def __init__(self, message: str, status: Optional[int] = None, response: Any = None):
        super().__init__(message)
        self.status = status
        self.response = response


def now_millis() -> int:
    return int(time.time() * 1000)


def new_device_id() -> str:
    return str(uuid.uuid4())


def load_env(path: Optional[str | Path] = None) -> Dict[str, str]:
    env: Dict[str, str] = {}
    if not path:
        return env
    env_path = Path(path)
    if not env_path.exists():
        raise AppFlowyError(f".env file not found: {env_path}")
    for raw in env_path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        value = value.strip().strip('"').strip("'")
        env[key.strip()] = value
    return env


@dataclass
class AppFlowyClient:
    base_url: Optional[str]
    gotrue_url: Optional[str]
    client_version: str
    device_id: str

    @classmethod
    def from_env(
        cls,
        env_path: Optional[str | Path] = None,
        base_url: Optional[str] = None,
        gotrue_url: Optional[str] = None,
        client_version: Optional[str] = None,
        device_id: Optional[str] = None,
    ) -> "AppFlowyClient":
        env = load_env(env_path)
        resolved_base = (base_url or os.environ.get("APPFLOWY_BASE_URL") or env.get("APPFLOWY_BASE_URL"))
        resolved_gotrue = (
            gotrue_url
            or os.environ.get("API_EXTERNAL_URL")
            or os.environ.get("APPFLOWY_GOTRUE_BASE_URL")
            or env.get("API_EXTERNAL_URL")
            or env.get("APPFLOWY_GOTRUE_BASE_URL")
            or ""
        )
        if resolved_base:
            resolved_base = resolved_base.rstrip("/")
        if resolved_gotrue:
            resolved_gotrue = resolved_gotrue.rstrip("/")
        if not resolved_gotrue:
            resolved_gotrue = None
        resolved_version = (
            client_version
            or os.environ.get("APPFLOWY_CLIENT_VERSION")
            or env.get("APPFLOWY_CLIENT_VERSION")
            or "0.12.3"
        )
        resolved_device = (
            device_id
            or os.environ.get("APPFLOWY_DEVICE_ID")
            or env.get("APPFLOWY_DEVICE_ID")
            or new_device_id()
        )
        return cls(
            base_url=resolved_base,
            gotrue_url=resolved_gotrue,
            client_version=resolved_version,
            device_id=resolved_device,
        )

    def _default_headers(self, [REDACTED_SECRET]) -> Dict[str, str]:
        headers = {
            "client-version": self.client_version,
            "client-timestamp": str(now_millis()),
            "device-id": self.device_id,
        }
        if [REDACTED_SECRET]"Authorization"] = f"Bearer {token}"
        return headers

    def _require_base_url(self) -> str:
        if not self.base_url:
            raise AppFlowyError(
                "APPFLOWY_BASE_URL not configured. Provide --base-url, use --config, set APPFLOWY_BASE_URL, or pass --env <path>."
            )
        return self.base_url

    def _require_gotrue_url(self) -> str:
        if not self.gotrue_url:
            raise AppFlowyError(
                "GoTrue base URL not configured. Provide --gotrue-url, use --config, set API_EXTERNAL_URL/APPFLOWY_GOTRUE_BASE_URL, or pass --env <path>."
            )
        return self.gotrue_url

    def _request_json(
        self,
        method: str,
        url: str,
        *,
        [REDACTED_SECRET] = None,
        params: Optional[Dict[str, Any]] = None,
        json_body: Optional[Dict[str, Any]] = None,
        timeout: int = 10,
    ) -> Any:
        if params:
            query = urlencode(params, doseq=True)
            url = f"{url}?{query}"
        headers = self._default_headers(token)
        data = None
        if json_body is not None:
            data = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
            headers["Content-Type"] = "application/json; charset=utf-8"
            headers["Accept-Charset"] = "utf-8"
        req = Request(url, data=data, method=method.upper(), headers=headers)
        try:
            with urlopen(req, timeout=timeout) as resp:
                body = resp.read()
                status = getattr(resp, "status", None)
        except HTTPError as exc:
            body = exc.read()
            status = exc.code
            raise AppFlowyError(f"HTTP {status} for {url}", status=status, response=body) from exc
        except URLError as exc:
            raise AppFlowyError(f"Request failed for {url}: {exc}") from exc

        if not body:
            return None
        try:
            payload = json.loads(body.decode("utf-8"))
        except json.JSONDecodeError:
            return body.decode("utf-8", errors="replace")

        if isinstance(payload, dict):
            if payload.get("success") is False or "error" in payload:
                raise AppFlowyError("AppFlowy API error", status=status, response=payload)
        return payload

    def login_password(self, email: str, password: str) -> Dict[str, Any]:
        base = self._require_gotrue_url()
        url = f"{base}/token"
        return self._request_json(
            "POST",
            url,
            params={"grant_type": "password"},
            json_body={"email": email, "password": password},
            token=None,
        )

    def list_workspaces(self, token: str) -> Any:
        base = self._require_base_url()
        return self._request_json("GET", f"{base}/api/workspace", token=token)

    def search(self, token: str, workspace_id: str, query: str) -> Any:
        base = self._require_base_url()
        return self._request_json(
            "GET",
            f"{base}/api/search/{workspace_id}",
            token=token,
            params={"query": query},
        )

    def list_databases(self, token: str, workspace_id: str) -> Any:
        base = self._require_base_url()
        return self._request_json(
            "GET",
            f"{base}/api/workspace/{workspace_id}/database",
            token=token,
        )

    def create_page_view(self, token: str, workspace_id: str, payload: Dict[str, Any]) -> Any:
        base = self._require_base_url()
        return self._request_json(
            "POST",
            f"{base}/api/workspace/{workspace_id}/page-view",
            token=token,
            json_body=payload,
        )

    def append_block(self, token: str, workspace_id: str, view_id: str, payload: Dict[str, Any]) -> Any:
        base = self._require_base_url()
        return self._request_json(
            "POST",
            f"{base}/api/workspace/{workspace_id}/page-view/{view_id}/append-block",
            token=token,
            json_body=payload,
        )

    def add_database_field(
        self, token: str, workspace_id: str, database_id: str, payload: Dict[str, Any]
    ) -> Any:
        base = self._require_base_url()
        return self._request_json(
            "POST",
            f"{base}/api/workspace/{workspace_id}/database/{database_id}/fields",
            token=token,
            json_body=payload,
        )

    def upsert_row(
        self, token: str, workspace_id: str, database_id: str, payload: Dict[str, Any]
    ) -> Any:
        base = self._require_base_url()
        return self._request_json(
            "POST",
            f"{base}/api/workspace/{workspace_id}/database/{database_id}/row",
            token=token,
            json_body=payload,
        )

    def get_row_detail(
        self, token: str, workspace_id: str, database_id: str, ids: Iterable[str]
    ) -> Any:
        base = self._require_base_url()
        ids_param = ",".join(ids)
        return self._request_json(
            "GET",
            f"{base}/api/workspace/{workspace_id}/database/{database_id}/row/detail",
            token=token,
            params={"ids": ids_param},
        )


path: scripts/appflowy_skill.py
content: import argparse
import subprocess
import sys
from pathlib import Path

COMMANDS = {
    "doctor": "doctor.py",
    "token": "get_token.py",
    "create-user-management-doc": "create_user_management_doc.py",
    "update-user-management-doc": "update_user_management_doc.py",
    "apply-grid": "apply_grid_template.py",
    "update-page-name": "update_page_name.py",
    "list-workspaces": "list_workspaces.py",
    "list-databases": "list_databases.py",
    "list-row-ids": "list_row_ids.py",
    "get-row-detail": "get_row_detail.py",
    "search": "search.py",
    "create-page-view": "create_page_view.py",
    "append-block": "append_block.py",
    "add-db-field": "add_db_field.py",
    "upsert-row": "upsert_row.py",
}


def _script_path(name: str) -> Path:
    return Path(__file__).resolve().parent / COMMANDS[name]


def _run_script(name: str, args: list[str]) -> int:
    script = _script_path(name)
    if not script.exists():
        print(f"脚本不存在：{script}", file=sys.stderr)
        return 1
    cmd = [sys.executable, str(script), *args]
    result = subprocess.run(cmd, check=False)
    return result.returncode


def _print_list() -> None:
    print("可用命令：")
    for key in sorted(COMMANDS.keys()):
        print(f"- {key}")


def main() -> int:
    parser = argparse.ArgumentParser(
        description="AppFlowy API 技能统一入口。使用 help 子命令查看具体脚本帮助。"
    )
    parser.add_argument(
        "command",
        help="子命令",
        choices=sorted(list(COMMANDS.keys()) + ["list", "help"]),
    )
    parser.add_argument("args", nargs=argparse.REMAINDER)
    args = parser.parse_args()

    if args.command == "list":
        _print_list()
        return 0

    if args.command == "help":
        if not args.args:
            print("用法：appflowy_skill.py help <command>", file=sys.stderr)
            _print_list()
            return 1
        target = args.args[0]
        if target not in COMMANDS:
            print(f"未知命令：{target}", file=sys.stderr)
            _print_list()
            return 1
        return _run_script(target, ["--help"])

    rest = args.args[1:] if args.args[:1] == ["--"] else args.args
    return _run_script(args.command, rest)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/apply_grid_template.py
content: import argparse
import json

import doc_grid_lib as grid_lib
from _common import build_client, load_json_payload, print_json, resolve_token
from appflowy_client import AppFlowyError


def load_template(path_or_payload: str | None, payload_file: str | None) -> dict:
    if path_or_payload or payload_file:
        return load_json_payload(path_or_payload, payload_file)
    raise AppFlowyError("Missing template payload. Use --template or --template-file.")


def ensure_fields_from_template(client, token, workspace_id: str, db_id: str, fields: list[dict]) -> None:
    existing = grid_lib.get_database_fields(client, token, workspace_id, db_id)
    field_list = existing.get("data", []) if isinstance(existing, dict) else []
    by_name = {f.get("name"): f for f in field_list if isinstance(f, dict)}
    for field in fields or []:
        name = field.get("name")
        if not name:
            continue
        if name in by_name and by_name[name].get("id"):
            continue
        payload = dict(field)
        type_option_data = payload.get("type_option_data")
        if (
            isinstance(type_option_data, dict)
            and type_option_data.get("database_id") == "<db_id_placeholder>"
        ):
            payload["type_option_data"] = {**type_option_data, "database_id": db_id}
        grid_lib.add_database_field(client, token, workspace_id, db_id, payload)


def main() -> int:
    parser = argparse.ArgumentParser(description="Apply a grid template to an existing document.")
    parser.add_argument("--config", default=None)
    parser.add_argument("--env", default=None)
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--view-id", required=True)
    parser.add_argument("--template", default=None, help="Template JSON string")
    parser.add_argument("--template-file", default=None, help="Template JSON file")
    parser.add_argument("--clean-only", action="store_true", help="Only clean invalid blocks/rows.")
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    template = load_template(args.template, args.template_file)

    grid_cfg = template.get("grid", {})
    grid_heading = grid_cfg.get("heading") or "Grid"
    grid_name = grid_cfg.get("name") or "Grid"
    clean_default_rows = bool(template.get("rules", {}).get("clean_default_rows", True))
    max_default_rows = int(template.get("rules", {}).get("max_default_rows", 3))

    doc_json = grid_lib.fetch_collab_json(
        client, token, args.workspace_id, args.view_id, grid_lib.DOC_COLLAB_TYPE
    )
    grid_section = grid_lib.select_grid_section(
        grid_lib.find_grid_sections(doc_json), heading_text=grid_heading
    )

    db_id = None
    db_view_id = None
    if grid_section:
        db_id = grid_section.get("parent_id")
        db_view_id = grid_section.get("view_id")

    if not db_id or not db_view_id:
        if args.clean_only:
            print_json(
                {
                    "workspace_id": args.workspace_id,
                    "view_id": args.view_id,
                    "note": "clean-only mode: grid not found, skipped creation",
                }
            )
            return 0
        resp = client.create_page_view(
            token,
            args.workspace_id,
            {"parent_view_id": args.view_id, "layout": grid_lib.GRID_LAYOUT, "name": grid_name},
        )
        data = resp.get("data") if isinstance(resp, dict) else None
        if not isinstance(data, dict):
            raise AppFlowyError("Failed to create grid page view", response=resp)
        db_id = data.get("database_id")
        db_view_id = data.get("view_id")
        if not db_id or not db_view_id:
            raise AppFlowyError("Missing database_id/view_id in create page response", response=resp)
        grid_lib.append_grid_section(
            client, token, args.workspace_id, args.view_id, grid_heading, db_id, db_view_id
        )

    removed_default_rows = []
    if clean_default_rows:
        removed_default_rows = grid_lib.cleanup_default_rows(
            client, token, args.workspace_id, db_id, max_remove=max_default_rows
        )

    if args.clean_only:
        print_json(
            {
                "workspace_id": args.workspace_id,
                "view_id": args.view_id,
                "grid_database_id": db_id,
                "grid_view_id": db_view_id,
                "default_rows_removed": removed_default_rows,
            }
        )
        return 0

    fields = template.get("fields") or []
    ensure_fields_from_template(client, token, args.workspace_id, db_id, fields)
    select_fields = []
    for field in fields:
        if field.get("field_type") in (3, 4) and field.get("type_option_data"):
            content = field.get("type_option_data", {}).get("content")
            data = None
            if isinstance(content, str):
                try:
                    data = json.loads(content)
                except json.JSONDecodeError:
                    data = None
            elif isinstance(content, dict):
                data = content
            if isinstance(data, dict):
                options = data.get("options") or []
                if options:
                    select_fields.append(
                        {
                            "name": field.get("name"),
                            "options": options,
                            "disable_color": data.get("disable_color", False),
                        }
                    )
    status_field = next((item for item in select_fields if item.get("name") == "状态"), None)
    if status_field and not any(item.get("name") == "Type" for item in select_fields):
        select_fields.append(
            {
                "name": "Type",
                "options": status_field.get("options") or [],
                "disable_color": status_field.get("disable_color", False),
            }
        )
    grid_lib.repair_select_field_options(
        client, token, args.workspace_id, db_id, select_fields
    )

    rows = template.get("rows") or []
    row_id_by_key: dict[str, str] = {}
    row_ids = []
    for row in rows:
        key = row.get("key")
        cells = row.get("cells") or {}
        if not key or not cells:
            continue
        pre_hash = f"{grid_name}:{key}"
        row_id = grid_lib.upsert_database_row(
            client, token, args.workspace_id, db_id, pre_hash, cells
        )
        row_id_by_key[key] = row_id
        row_ids.append(row_id)

    for row in rows:
        key = row.get("key")
        if not key:
            continue
        rel_cells = {}
        depends_on = row.get("depends_on") or []
        children = row.get("children") or []
        if depends_on:
            rel_cells["依赖"] = {
                "row_ids": [row_id_by_key[k] for k in depends_on if k in row_id_by_key]
            }
        if children:
            rel_cells["子项"] = {
                "row_ids": [row_id_by_key[k] for k in children if k in row_id_by_key]
            }
        if rel_cells:
            pre_hash = f"{grid_name}:{key}"
            grid_lib.upsert_database_row(
                client, token, args.workspace_id, db_id, pre_hash, rel_cells
            )

    print_json(
        {
            "workspace_id": args.workspace_id,
            "view_id": args.view_id,
            "grid_database_id": db_id,
            "grid_view_id": db_view_id,
            "default_rows_removed": removed_default_rows,
            "rows_upserted": len(row_ids),
        }
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/collab_delete_blocks.mjs
content: import fs from "fs";
import * as Y from "yjs";

function readStdin() {
  const input = fs.readFileSync(0, "utf-8").trim();
  if (!input) {
    throw new Error("Missing JSON input on stdin");
  }
  return JSON.parse(input);
}

function asArray(value) {
  if (!value) return [];
  if (Array.isArray(value)) return value.slice();
  if (typeof value.toArray === "function") return value.toArray();
  return [];
}

function getMapValue(map, key) {
  if (!map) return undefined;
  if (typeof map.get === "function") {
    return map.get(key);
  }
  return map[key];
}

function deleteFromArray(arr, target) {
  if (!arr) return;
  if (Array.isArray(arr)) {
    const idx = arr.indexOf(target);
    if (idx >= 0) arr.splice(idx, 1);
    return;
  }
  if (typeof arr.toArray === "function" && typeof arr.delete === "function") {
    const list = arr.toArray();
    const idx = list.indexOf(target);
    if (idx >= 0) arr.delete(idx, 1);
  }
}

const input = readStdin();
const docState = input.doc_state;
const stateVector = input.state_vector;
const deleteBlockIds = input.delete_block_ids || [];
if (!Array.isArray(docState) || !Array.isArray(deleteBlockIds)) {
  throw new Error("Invalid input: doc_state and delete_block_ids must be arrays");
}

const doc = new Y.Doc();
Y.applyUpdate(doc, Uint8Array.from(docState));

const dataRoot = doc.getMap("data");
const root = dataRoot.get("document");
if (!root) {
  throw new Error("Missing document map in collab data");
}
const blocks = root.get("blocks");
const meta = root.get("meta");
const childrenMap = meta && typeof meta.get === "function" ? meta.get("children_map") : undefined;
const textMap = meta && typeof meta.get === "function" ? meta.get("text_map") : undefined;

function getBlock(id) {
  return getMapValue(blocks, id);
}

function getBlockField(block, key) {
  return getMapValue(block, key);
}

function getChildrenIds(blockId) {
  const block = getBlock(blockId);
  if (!block) return [];
  const childrenKey = getBlockField(block, "children");
  if (!childrenKey) return [];
  const arr = getMapValue(childrenMap, childrenKey);
  return asArray(arr);
}

const toDelete = new Set();

function collectDescendants(blockId) {
  if (toDelete.has(blockId)) return;
  toDelete.add(blockId);
  const children = getChildrenIds(blockId);
  for (const childId of children) {
    collectDescendants(childId);
  }
}

for (const id of deleteBlockIds) {
  collectDescendants(id);
}

function removeFromParent(blockId) {
  const block = getBlock(blockId);
  if (!block) return;
  const parentId = getBlockField(block, "parent");
  if (!parentId) return;
  const parent = getBlock(parentId);
  if (!parent) return;
  const parentChildrenKey = getBlockField(parent, "children");
  if (!parentChildrenKey) return;
  const arr = getMapValue(childrenMap, parentChildrenKey);
  deleteFromArray(arr, blockId);
}

for (const blockId of toDelete) {
  const block = getBlock(blockId);
  if (!block) continue;

  removeFromParent(blockId);

  const childrenKey = getBlockField(block, "children");
  if (childrenKey) {
    if (typeof childrenMap.delete === "function") {
      childrenMap.delete(childrenKey);
    } else {
      delete childrenMap[childrenKey];
    }
  }

  const externalId = getBlockField(block, "external_id");
  if (externalId) {
    if (typeof textMap.delete === "function") {
      textMap.delete(externalId);
    } else {
      delete textMap[externalId];
    }
  }

  if (typeof blocks.delete === "function") {
    blocks.delete(blockId);
  } else {
    delete blocks[blockId];
  }
}

const update = Array.isArray(stateVector)
  ? Y.encodeStateAsUpdate(doc, Uint8Array.from(stateVector))
  : Y.encodeStateAsUpdate(doc);
const output = { update: Array.from(update) };
process.stdout.write(JSON.stringify(output));


path: scripts/collab_delete_row_orders.mjs
content: import fs from "fs";
import * as Y from "yjs";

function readStdin() {
  const input = fs.readFileSync(0, "utf-8").trim();
  if (!input) {
    throw new Error("Missing JSON input on stdin");
  }
  return JSON.parse(input);
}

function asArray(value) {
  if (!value) return [];
  if (Array.isArray(value)) return value.slice();
  if (typeof value.toArray === "function") return value.toArray();
  return [];
}

function getMapValue(map, key) {
  if (!map) return undefined;
  if (typeof map.get === "function") {
    return map.get(key);
  }
  return map[key];
}


function deleteFromArray(arr, idx) {
  if (!arr) return;
  if (Array.isArray(arr)) {
    if (idx >= 0) arr.splice(idx, 1);
    return;
  }
  if (typeof arr.delete === "function") {
    arr.delete(idx, 1);
  }
}

function listKeys(map) {
  if (!map) return [];
  if (typeof map.keys === "function") {
    return Array.from(map.keys());
  }
  return Object.keys(map);
}

const input = readStdin();
const docState = input.doc_state;
const stateVector = input.state_vector;
const rowIds = new Set(input.row_ids || []);
const viewIds = input.view_ids || [];

if (!Array.isArray(docState) || !Array.isArray(input.row_ids)) {
  throw new Error("Invalid input: doc_state and row_ids must be arrays");
}

const doc = new Y.Doc();
Y.applyUpdate(doc, Uint8Array.from(docState));

const dataRoot = doc.getMap("data");
const database = getMapValue(dataRoot, "database");
if (!database) {
  throw new Error("Missing database map in collab data");
}

const views = getMapValue(database, "views");
if (!views) {
  throw new Error("Missing views map in database collab");
}

const targetViewIds = viewIds.length > 0 ? viewIds : listKeys(views);

for (const viewId of targetViewIds) {
  const view = getMapValue(views, viewId);
  if (!view) continue;
  const rowOrders = getMapValue(view, "row_orders");
  if (!rowOrders) continue;
  const rows = asArray(rowOrders);
  for (let i = rows.length - 1; i >= 0; i -= 1) {
    const row = rows[i];
    const rowId = getMapValue(row, "id");
    if (rowIds.has(rowId)) {
      deleteFromArray(rowOrders, i);
    }
  }
  // row_orders 已在原地更新，无需重新 set
}

const update = Array.isArray(stateVector)
  ? Y.encodeStateAsUpdate(doc, Uint8Array.from(stateVector))
  : Y.encodeStateAsUpdate(doc);
const output = { update: Array.from(update) };
process.stdout.write(JSON.stringify(output));


path: scripts/collab_update_select_options.mjs
content: import fs from "fs";
import * as Y from "yjs";

function readStdin() {
  const input = fs.readFileSync(0, "utf-8").trim();
  if (!input) {
    throw new Error("Missing JSON input on stdin");
  }
  return JSON.parse(input);
}

function getMapValue(map, key) {
  if (!map) return undefined;
  if (typeof map.get === "function") {
    return map.get(key);
  }
  return map[key];
}

function setMapValue(map, key, value) {
  if (!map) return;
  if (typeof map.set === "function") {
    map.set(key, value);
  } else {
    map[key] = value;
  }
}

function ensureYMap(value) {
  if (value instanceof Y.Map || (value && value.constructor && value.constructor.name === "YMap")) {
    return { map: value, created: false };
  }
  const map = new Y.Map();
  if (value && typeof value === "object") {
    for (const [key, val] of Object.entries(value)) {
      map.set(key, val);
    }
  }
  return { map, created: true };
}

const input = readStdin();
const docState = input.doc_state;
const stateVector = input.state_vector;
const fieldUpdates = input.field_updates || [];

if (!Array.isArray(docState) || !Array.isArray(fieldUpdates)) {
  throw new Error("Invalid input: doc_state and field_updates must be arrays");
}

const doc = new Y.Doc();
Y.applyUpdate(doc, Uint8Array.from(docState));

const dataRoot = doc.getMap("data");
const database = getMapValue(dataRoot, "database");
if (!database) {
  throw new Error("Missing database map in collab data");
}

const fields = getMapValue(database, "fields");
if (!fields) {
  throw new Error("Missing fields map in database collab");
}

for (const update of fieldUpdates) {
  const fieldId = update.field_id;
  const content = update.content;
  const typeKey = update.type_key || "3";
  if (!fieldId || typeof content !== "string") continue;
  const rawField = getMapValue(fields, fieldId);
  if (!rawField) continue;
  const { map: fieldMap, created: fieldCreated } = ensureYMap(rawField);
  if (fieldCreated) {
    setMapValue(fields, fieldId, fieldMap);
  }
  const typeOptionRaw = getMapValue(fieldMap, "type_option");
  const { map: typeOption, created: optionCreated } = ensureYMap(typeOptionRaw);
  if (optionCreated) {
    setMapValue(fieldMap, "type_option", typeOption);
  }
  const typeDataRaw = getMapValue(typeOption, typeKey);
  const { map: typeData, created: dataCreated } = ensureYMap(typeDataRaw);
  if (dataCreated) {
    setMapValue(typeOption, typeKey, typeData);
  }
  setMapValue(typeData, "content", content);
}

const update = Array.isArray(stateVector)
  ? Y.encodeStateAsUpdate(doc, Uint8Array.from(stateVector))
  : Y.encodeStateAsUpdate(doc);
const output = { update: Array.from(update) };
process.stdout.write(JSON.stringify(output));


path: scripts/create_page_view.py
content: import argparse

from _common import build_client, load_json_payload, print_json, resolve_token


def main() -> int:
    parser = argparse.ArgumentParser(description="Create a page view in AppFlowy.")
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--payload", default=None, help="JSON string payload")
    parser.add_argument("--payload-file", default=None, help="Path to JSON payload file")
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    payload = load_json_payload(args.payload, args.payload_file)
    result = client.create_page_view(token, args.workspace_id, payload)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/create_user_management_doc.py
content: import argparse
import json
from pathlib import Path

from _common import build_client, print_json, resolve_token
from appflowy_client import AppFlowyError


def load_template(path: str) -> list[dict]:
    template_path = Path(path)
    if not template_path.exists():
        raise AppFlowyError(f"Template file not found: {template_path}")
    payload = json.loads(template_path.read_text(encoding="utf-8"))
    blocks = payload.get("blocks")
    if not isinstance(blocks, list) or not blocks:
        raise AppFlowyError("Template must contain non-empty 'blocks' array")
    return blocks


def pick_workspace_id(client, token: str, workspace_id: str | None) -> str:
    if workspace_id:
        return workspace_id
    data = client.list_workspaces(token)
    items = data.get("data") if isinstance(data, dict) else None
    if not items:
        raise AppFlowyError("No workspace found")
    return items[0].get("workspace_id") or items[0].get("id")


def pick_parent_view_id(client, token: str, workspace_id: str, parent_view_id: str | None) -> str:
    if parent_view_id:
        return parent_view_id
    folder = client._request_json(
        "GET",
        f"{client._require_base_url()}/api/workspace/{workspace_id}/folder",
        token=token,
        params={"depth": 1},
    )
    children = folder.get("data", {}).get("children", [])
    for child in children:
        if child.get("name") == "General":
            return child.get("view_id")
    if children:
        return children[0].get("view_id")
    return workspace_id


def main() -> int:
    parser = argparse.ArgumentParser(description="Create a user management doc page (test).")
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--workspace-id", default=None)
    parser.add_argument("--parent-view-id", default=None)
    parser.add_argument(
        "--title",
        default="用户管理系统开发文档（测试）-UTF8",
        help="Page title to create.",
    )
    parser.add_argument(
        "--template",
        default="skills/appflowy-api/references/templates/user_management_doc.json",
        help="Path to UTF-8 JSON template with blocks.",
    )
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    workspace_id = pick_workspace_id(client, token, args.workspace_id)
    parent_view_id = pick_parent_view_id(client, token, workspace_id, args.parent_view_id)

    page_payload = {
        "parent_view_id": parent_view_id,
        "layout": 0,
        "name": args.title,
    }
    page_resp = client.create_page_view(token, workspace_id, page_payload)
    view_id = None
    if isinstance(page_resp, dict):
        data = page_resp.get("data") or page_resp
        if isinstance(data, dict):
            view_id = data.get("view_id")
    if not view_id:
        raise AppFlowyError("Failed to create page view (missing view_id)")

    blocks = load_template(args.template)
    append_payload = {"blocks": blocks}
    append_resp = client.append_block(token, workspace_id, view_id, append_payload)

    print_json(
        {
            "workspace_id": workspace_id,
            "parent_view_id": parent_view_id,
            "view_id": view_id,
            "create_page_response": page_resp,
            "append_block_response": append_resp,
        }
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/doc_grid_lib.py
content: import json
import subprocess
from pathlib import Path

from appflowy_client import AppFlowyError

DOC_COLLAB_TYPE = 0
DB_COLLAB_TYPE = 1
GRID_LAYOUT = 1


def fetch_collab_json(client, token: str, workspace_id: str, object_id: str, collab_type: int) -> dict:
    base = client._require_base_url()
    return client._request_json(
        "GET",
        f"{base}/api/workspace/v1/{workspace_id}/collab/{object_id}/json",
        token=token,
        params={"collab_type": collab_type},
    )


def fetch_collab_state(
    client, token: str, workspace_id: str, object_id: str, collab_type: int
) -> tuple[list[int], list[int]]:
    base = client._require_base_url()
    payload = client._request_json(
        "GET",
        f"{base}/api/workspace/v1/{workspace_id}/collab/{object_id}",
        token=token,
        params={"collab_type": collab_type},
    )
    data = payload.get("data") if isinstance(payload, dict) else None
    if not data or "doc_state" not in data:
        raise AppFlowyError("Missing doc_state in collab response", response=payload)
    return data["doc_state"], data.get("state_vector", [])


def post_web_update(
    client, token: str, workspace_id: str, object_id: str, collab_type: int, update: list[int]
) -> dict:
    base = client._require_base_url()
    payload = {"doc_state": update, "collab_type": collab_type}
    return client._request_json(
        "POST",
        f"{base}/api/workspace/v1/{workspace_id}/collab/{object_id}/web-update",
        token=token,
        json_body=payload,
    )


def list_databases(client, token: str, workspace_id: str) -> dict:
    return client.list_databases(token, workspace_id)


def get_database_fields(client, token: str, workspace_id: str, database_id: str) -> dict:
    base = client._require_base_url()
    return client._request_json(
        "GET",
        f"{base}/api/workspace/{workspace_id}/database/{database_id}/fields",
        token=token,
    )


def add_database_field(client, token: str, workspace_id: str, database_id: str, field: dict) -> str:
    base = client._require_base_url()
    resp = client._request_json(
        "POST",
        f"{base}/api/workspace/{workspace_id}/database/{database_id}/fields",
        token=token,
        json_body=field,
    )
    data = resp.get("data") if isinstance(resp, dict) else None
    if not data:
        raise AppFlowyError("Failed to add database field", response=resp)
    return data


def add_database_row(client, token: str, workspace_id: str, database_id: str, cells: dict) -> str:
    base = client._require_base_url()
    resp = client._request_json(
        "POST",
        f"{base}/api/workspace/{workspace_id}/database/{database_id}/row",
        token=token,
        json_body={"cells": cells},
    )
    data = resp.get("data") if isinstance(resp, dict) else None
    if not data:
        raise AppFlowyError("Failed to add database row", response=resp)
    return data


def upsert_database_row(
    client,
    token: str,
    workspace_id: str,
    database_id: str,
    pre_hash: str,
    cells: dict,
) -> str:
    base = client._require_base_url()
    resp = client._request_json(
        "PUT",
        f"{base}/api/workspace/{workspace_id}/database/{database_id}/row",
        token=token,
        json_body={"pre_hash": pre_hash, "cells": cells},
    )
    data = resp.get("data") if isinstance(resp, dict) else None
    if not data:
        raise AppFlowyError("Failed to upsert database row", response=resp)
    return data


def list_row_ids(client, token: str, workspace_id: str, database_id: str) -> list[str]:
    base = client._require_base_url()
    resp = client._request_json(
        "GET",
        f"{base}/api/workspace/{workspace_id}/database/{database_id}/row",
        token=token,
    )
    rows = resp.get("data") if isinstance(resp, dict) else []
    ids = []
    for item in rows or []:
        row_id = item.get("id") if isinstance(item, dict) else None
        if row_id:
            ids.append(row_id)
    return ids


def get_row_details(client, token: str, workspace_id: str, database_id: str, row_ids: list[str]) -> list[dict]:
    if not row_ids:
        return []
    base = client._require_base_url()
    resp = client._request_json(
        "GET",
        f"{base}/api/workspace/{workspace_id}/database/{database_id}/row/detail",
        token=token,
        params={"ids": ",".join(row_ids)},
    )
    return resp.get("data") if isinstance(resp, dict) else []


def create_database_view(client, token: str, workspace_id: str, view_id: str, name: str) -> None:
    base = client._require_base_url()
    try:
        client._request_json(
            "POST",
            f"{base}/api/workspace/{workspace_id}/page-view/{view_id}/database-view",
            token=token,
            json_body={"layout": GRID_LAYOUT, "name": name},
        )
        return
    except AppFlowyError as exc:
        message = str(exc)
        response = getattr(exc, "response", None)
        if response and b"parent_view_id" in response:
            pass
        elif "parent_view_id" in message:
            pass
        else:
            raise

    client.create_page_view(
        token,
        workspace_id,
        {"parent_view_id": view_id, "layout": GRID_LAYOUT, "name": name},
    )


def append_grid_section(
    client, token: str, workspace_id: str, view_id: str, heading: str, db_id: str, db_view_id: str
) -> dict:
    payload = {
        "blocks": [
            {"type": "heading", "data": {"level": 2, "delta": [{"insert": heading}]}},
            {"type": "grid", "data": {"parent_id": db_id, "view_id": db_view_id}},
        ]
    }
    return client.append_block(token, workspace_id, view_id, payload)


def parse_table_rows(blocks: dict, children_map: dict, text_map: dict, table_id: str) -> list[list[str]]:
    def children_of(block_id: str) -> list[str]:
        block = blocks.get(block_id)
        if not block:
            return []
        return children_map.get(block.get("children"), [])

    def paragraph_text(paragraph_id: str) -> str:
        block = blocks.get(paragraph_id)
        if not block:
            return ""
        ext_id = block.get("external_id")
        if not ext_id:
            return ""
        return text_map.get(ext_id, "")

    def cell_text(cell_id: str) -> str:
        for child_id in children_of(cell_id):
            child = blocks.get(child_id)
            if child and child.get("ty") == "paragraph":
                return paragraph_text(child_id).replace("\n", "").strip()
        return ""

    rows = []
    for row_id in children_of(table_id):
        row_cells = [cell_text(cell_id) for cell_id in children_of(row_id)]
        rows.append(row_cells)
    return rows


def block_text(blocks: dict, children_map: dict, text_map: dict, block_id: str) -> str:
    block = blocks.get(block_id)
    if not isinstance(block, dict):
        return ""
    ext_id = block.get("external_id")
    if ext_id and ext_id in text_map:
        return str(text_map.get(ext_id, "")).replace("\n", "").strip()
    children_key = block.get("children")
    if not children_key:
        return ""
    child_ids = children_map.get(children_key, [])
    for child_id in child_ids:
        child = blocks.get(child_id)
        if isinstance(child, dict) and child.get("external_id"):
            text = text_map.get(child.get("external_id"), "")
            if text:
                return str(text).replace("\n", "").strip()
    return ""


def page_children_order(doc: dict) -> list[str]:
    page_id = doc.get("page_id")
    meta = doc.get("meta", {})
    children_map = meta.get("children_map", {})
    if not page_id:
        return []
    return children_map.get(page_id, [])


def find_grid_sections(doc_json: dict) -> list[dict]:
    doc = doc_json.get("data", {}).get("collab", {}).get("document", {})
    blocks = doc.get("blocks", {})
    meta = doc.get("meta", {})
    children_map = meta.get("children_map", {})
    text_map = meta.get("text_map", {})
    ordered = page_children_order(doc)

    sections = []
    current_heading = {"id": None, "text": ""}
    for block_id in ordered:
        block = blocks.get(block_id)
        if not isinstance(block, dict):
            continue
        if block.get("ty") == "heading":
            current_heading = {
                "id": block_id,
                "text": block_text(blocks, children_map, text_map, block_id),
            }
            continue
        if block.get("ty") != "grid":
            continue
        raw_data = block.get("data")
        if isinstance(raw_data, str):
            try:
                data = json.loads(raw_data)
            except json.JSONDecodeError:
                data = {}
        elif isinstance(raw_data, dict):
            data = raw_data
        else:
            data = {}
        sections.append(
            {
                "block_id": block_id,
                "parent_id": data.get("parent_id"),
                "view_id": data.get("view_id"),
                "heading_id": current_heading.get("id"),
                "heading_text": current_heading.get("text", ""),
            }
        )
    return sections


def select_grid_section(sections: list[dict], heading_text: str | None = None) -> dict | None:
    if not sections:
        return None
    if heading_text:
        matched = None
        for item in sections:
            if item.get("heading_text") == heading_text:
                matched = item
        if matched:
            return matched
    return sections[0]


def run_node_delete_doc_blocks(
    doc_state: list[int], state_vector: list[int], delete_block_ids: list[str]
) -> list[int]:
    script_path = Path(__file__).resolve().parent / "collab_delete_blocks.mjs"
    if not script_path.exists():
        raise AppFlowyError(f"Missing script: {script_path}")

    payload = json.dumps(
        {
            "doc_state": doc_state,
            "state_vector": state_vector,
            "delete_block_ids": delete_block_ids,
        }
    )
    result = subprocess.run(
        ["node", str(script_path)],
        input=payload,
        text=True,
        capture_output=True,
        check=False,
    )
    if result.returncode != 0:
        raise AppFlowyError(f"Node script failed: {result.stderr.strip()}")
    try:
        output = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise AppFlowyError("Failed to parse Node output") from exc
    update = output.get("update")
    if not isinstance(update, list):
        raise AppFlowyError("Node output missing update")
    return update


def run_node_delete_row_orders(
    doc_state: list[int],
    state_vector: list[int],
    row_ids: list[str],
    view_ids: list[str] | None = None,
) -> list[int]:
    script_path = Path(__file__).resolve().parent / "collab_delete_row_orders.mjs"
    if not script_path.exists():
        raise AppFlowyError(f"Missing script: {script_path}")

    payload = json.dumps(
        {
            "doc_state": doc_state,
            "state_vector": state_vector,
            "row_ids": row_ids,
            "view_ids": view_ids or [],
        }
    )
    result = subprocess.run(
        ["node", str(script_path)],
        input=payload,
        text=True,
        capture_output=True,
        check=False,
    )
    if result.returncode != 0:
        raise AppFlowyError(f"Node script failed: {result.stderr.strip()}")
    try:
        output = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise AppFlowyError("Failed to parse Node output") from exc
    update = output.get("update")
    if not isinstance(update, list):
        raise AppFlowyError("Node output missing update")
    return update


def run_node_update_select_options(
    doc_state: list[int],
    state_vector: list[int],
    field_updates: list[dict],
) -> list[int]:
    script_path = Path(__file__).resolve().parent / "collab_update_select_options.mjs"
    if not script_path.exists():
        raise AppFlowyError(f"Missing script: {script_path}")

    payload = json.dumps(
        {
            "doc_state": doc_state,
            "state_vector": state_vector,
            "field_updates": field_updates,
        }
    )
    result = subprocess.run(
        ["node", str(script_path)],
        input=payload,
        text=True,
        capture_output=True,
        check=False,
    )
    if result.returncode != 0:
        raise AppFlowyError(f"Node script failed: {result.stderr.strip()}")
    try:
        output = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise AppFlowyError("Failed to parse Node output") from exc
    update = output.get("update")
    if not isinstance(update, list):
        raise AppFlowyError("Node output missing update")
    return update


def is_empty_value(value: object) -> bool:
    if value is None:
        return True
    if isinstance(value, str):
        return value.strip() == ""
    if isinstance(value, bool):
        return value is False
    if isinstance(value, (int, float)):
        return False
    if isinstance(value, list):
        return len(value) == 0
    if isinstance(value, dict):
        if not value:
            return True
        if "options" in value and "selected_option_ids" in value:
            return not value.get("options") and not value.get("selected_option_ids")
        if "files" in value:
            files = value.get("files")
            return not files
        if "start" in value and "end" in value:
            return not value.get("start") and not value.get("end")
    return False


def is_row_empty(cells: dict, ignore_fields: set[str]) -> bool:
    for name, value in cells.items():
        if name in ignore_fields:
            continue
        if not is_empty_value(value):
            return False
    return True


def cleanup_default_rows(
    client,
    token: str,
    workspace_id: str,
    database_id: str,
    *,
    max_remove: int = 3,
    view_ids: list[str] | None = None,
) -> list[str]:
    row_ids = list_row_ids(client, token, workspace_id, database_id)
    if not row_ids:
        return []

    row_details = get_row_details(client, token, workspace_id, database_id, row_ids)
    ignore_fields = {
        "创建时间",
        "最后编辑时间",
        "人员",
        "Done",
        "Created time",
        "Last edited time",
        "Created Time",
        "Last Edited Time",
        "Assignee",
    }
    empty_ids = []
    for row in row_details:
        row_id = row.get("id") if isinstance(row, dict) else None
        cells = row.get("cells") if isinstance(row, dict) else None
        if not row_id or not isinstance(cells, dict):
            continue
        if is_row_empty(cells, ignore_fields):
            empty_ids.append(row_id)
    if not empty_ids:
        return []

    empty_ids = empty_ids[:max_remove]
    doc_state, state_vector = fetch_collab_state(
        client, token, workspace_id, database_id, DB_COLLAB_TYPE
    )
    update = run_node_delete_row_orders(doc_state, state_vector, empty_ids, view_ids=view_ids)
    post_web_update(client, token, workspace_id, database_id, DB_COLLAB_TYPE, update)
    return empty_ids


def build_select_content(options: list[dict], disable_color: bool = False) -> str:
    payload = {"options": options, "disable_color": disable_color}
    return json.dumps(payload, ensure_ascii=False)


def repair_select_field_options(
    client,
    token: str,
    workspace_id: str,
    database_id: str,
    select_fields: list[dict],
) -> list[str]:
    if not select_fields:
        return []

    desired_by_name = {}
    for field in select_fields:
        name = field.get("name")
        options = field.get("options")
        if name and options:
            desired_by_name[name] = {
                "content": build_select_content(options, field.get("disable_color", False))
            }

    fields_resp = get_database_fields(client, token, workspace_id, database_id)
    fields = fields_resp.get("data", []) if isinstance(fields_resp, dict) else []

    def extract_options(content: object) -> list[dict]:
        if isinstance(content, str):
            try:
                data = json.loads(content)
            except json.JSONDecodeError:
                return []
            if isinstance(data, dict):
                return data.get("options") or []
            return []
        if isinstance(content, dict):
            return content.get("options") or []
        return []

    updates = []
    for field in fields:
        name = field.get("name")
        field_type = field.get("field_type")
        if not name or name not in desired_by_name:
            continue
        if field_type not in ("SingleSelect", "MultiSelect"):
            continue
        type_key = "3" if field_type == "SingleSelect" else "4"
        type_option = field.get("type_option") or {}
        content = type_option.get("content") or {}
        options = extract_options(content)
        if not options:
            updates.append(
                {
                    "field_id": field.get("id"),
                    "type_key": type_key,
                    "content": desired_by_name[name]["content"],
                }
            )
            continue
        if any(isinstance(opt.get("color"), int) for opt in options if isinstance(opt, dict)):
            updates.append(
                {
                    "field_id": field.get("id"),
                    "type_key": type_key,
                    "content": desired_by_name[name]["content"],
                }
            )

    if not updates:
        return []

    doc_state, state_vector = fetch_collab_state(
        client, token, workspace_id, database_id, DB_COLLAB_TYPE
    )
    update = run_node_update_select_options(doc_state, state_vector, updates)
    post_web_update(client, token, workspace_id, database_id, DB_COLLAB_TYPE, update)
    return [item.get("field_id") for item in updates if item.get("field_id")]


path: scripts/doctor.py
content: import argparse
import json
import sys
from typing import Any, Dict, List

import os

from _common import build_client, load_config, print_json, resolve_token
from appflowy_client import AppFlowyError


def _check(name: str, fn) -> Dict[str, Any]:
    try:
        result = fn()
        return {"name": name, "ok": True, "result": result}
    except Exception as exc:  # pylint: disable=broad-except
        return {"name": name, "ok": False, "error": str(exc)}


def _extract_version(payload: Any) -> str | None:
    if isinstance(payload, dict):
        for key in (
            "version",
            "appflowy_version",
            "appflowy_cloud_version",
            "appflowyCloudVersion",
        ):
            if key in payload and payload[key]:
                return str(payload[key])
        data = payload.get("data")
        if isinstance(data, dict):
            for key in (
                "version",
                "appflowy_version",
                "appflowy_cloud_version",
                "appflowyCloudVersion",
            ):
                if key in data and data[key]:
                    return str(data[key])
    return None


def main() -> int:
    parser = argparse.ArgumentParser(description="AppFlowy API self-check.")
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--expected-version", default=None, help="Expected AppFlowy Cloud version.")
    parser.add_argument("--device-id", default=None)
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--timeout", type=int, default=10)
    parser.add_argument("--check-gotrue", action="store_true")
    parser.add_argument("--check-appflowy", action="store_true")
    parser.add_argument("--check-workspace", action="store_true")
    args = parser.parse_args()

    cfg = load_config(args.config)
    client = build_client(args)
    checks: List[Dict[str, Any]] = []
    warnings: List[str] = []

    def want(name: str) -> bool:
        if args.check_gotrue or args.check_appflowy or args.check_workspace:
            return {
                "gotrue": args.check_gotrue,
                "appflowy": args.check_appflowy,
                "workspace": args.check_workspace,
            }[name]
        return True

    if want("gotrue"):
        checks.append(
            _check(
                "gotrue_health",
                lambda: client._request_json(
                    "GET",
                    f"{client._require_gotrue_url()}/health",
                    token=None,
                    timeout=args.timeout,
                ),
            )
        )

    token = None
    if args.token or (args.email and args.password):
        try:
            [REDACTED_SECRET], client)
        except AppFlowyError as exc:
            checks.append({"name": "auth", "ok": False, "error": str(exc)})

    appflowy_health = None
    if want("appflowy"):
        checks.append(
            _check(
                "appflowy_health",
                lambda: client._request_json(
                    "GET",
                    f"{client._require_base_url()}/api/health",
                    token=None,
                    timeout=args.timeout,
                ),
            )
        )
        if checks[-1]["ok"]:
            appflowy_health = checks[-1]["result"]

    if want("workspace") and [REDACTED_SECRET]
            _check(
                "list_workspaces",
                lambda: client.list_workspaces(token),
            )
        )

    expected_version = (
        args.expected_version
        or cfg.get("expected_appflowy_version")
        or cfg.get("expected_version")
        or os.environ.get("APPFLOWY_EXPECTED_VERSION")
    )
    if expected_version and appflowy_health:
        server_version = _extract_version(appflowy_health)
        if server_version and server_version != expected_version:
            warnings.append(
                f"AppFlowy version mismatch: expected {expected_version}, got {server_version}. Skill may be incompatible."
            )
        if not server_version:
            warnings.append(
                "Unable to detect AppFlowy version from /api/health response. Skill may be incompatible."
            )

    ok = all(c.get("ok") for c in checks) if checks else False
    output = {"ok": ok, "checks": checks, "warnings": warnings}
    print_json(output)
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/get_row_detail.py
content: import argparse

from _common import build_client, print_json, resolve_token


def main() -> int:
    parser = argparse.ArgumentParser(description="Get database row details.")
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--database-id", required=True)
    parser.add_argument("--ids", required=True, help="Comma separated row ids")
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    ids = [s for s in args.ids.split(",") if s]
    result = client.get_row_detail(token, args.workspace_id, args.database_id, ids)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/get_token.py
content: import argparse
from _common import build_client, print_json


def main() -> int:
    parser = argparse.ArgumentParser(description="Fetch GoTrue access token.")
    parser.add_argument("--email", required=True)
    parser.add_argument("--password", required=True)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], args.password)
    print_json(token)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/list_databases.py
content: import argparse

from _common import build_client, print_json, resolve_token


def main() -> int:
    parser = argparse.ArgumentParser(description="List databases in a workspace.")
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    result = client.list_databases(token, args.workspace_id)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/list_row_ids.py
content: import argparse

from _common import build_client, print_json, resolve_token


def main() -> int:
    parser = argparse.ArgumentParser(description="List row ids of a database.")
    parser.add_argument("--workspace-id", required=True)
    parser.add_argument("--database-id", required=True)
    parser.add_argument("--token", default=None)
    parser.add_argument("--email", default=None)
    parser.add_argument("--password", default=None)
    parser.add_argument("--config", default=None, help="Path to config JSON (optional).")
    parser.add_argument("--env", default=None, help="Path to .env file (optional, opt-in).")
    parser.add_argument("--base-url", default=None)
    parser.add_argument("--gotrue-url", default=None)
    parser.add_argument("--client-version", default=None)
    parser.add_argument("--device-id", default=None)
    args = parser.parse_args()

    client = build_client(args)
    [REDACTED_SECRET], client)
    base = client._require_base_url()
    result = client._request_json(
        "GET",
        f"{base}/api/workspace/{args.workspace_id}/database/{args.database_id}/row",
        token=token,
    )
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/package.json
content: {
  "name": "scripts",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "yjs": "^13.6.18"
  }
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)