@amaster.ai/pi-lark 0.1.2-beta.43 → 0.1.2-beta.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/lark-approval/references/lark-approval-initiate.md +2 -5
- package/skills/lark-approval/references/lark-approval-instances-initiated.md +6 -0
- package/skills/lark-approval/references/lark-approval-tasks-query.md +9 -0
- package/skills/lark-approval/references/lark-approval-tasks-rollback.md +8 -2
- package/skills/lark-base/SKILL.md +5 -1
- package/skills/lark-base/references/lark-base-view-set-filter.md +3 -1
- package/skills/lark-event/SKILL.md +2 -1
- package/skills/lark-event/references/lark-event-approval.md +170 -0
- package/skills/lark-slides/references/xml-format-guide.md +0 -5
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +264 -6
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +348 -6
- package/skills/lark-vc/SKILL.md +6 -3
- package/skills/lark-vc/references/vc-domain-boundaries.md +9 -1
- package/skills/lark-vc-agent/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amaster.ai/pi-lark",
|
|
3
|
-
"version": "0.1.2-beta.
|
|
3
|
+
"version": "0.1.2-beta.44",
|
|
4
4
|
"description": "Pi extension for Lark/Feishu workspace — calendar, docs, drive, sheets, tasks, mail and more via lark-cli.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"vitest": "^4.0.0"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@amaster.ai/pi-shared": "0.1.2-beta.
|
|
64
|
+
"@amaster.ai/pi-shared": "0.1.2-beta.44"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
67
|
"fetch-skills": "node scripts/fetch-skills.mjs",
|
|
@@ -69,19 +69,17 @@ lark-cli approval approvals get \
|
|
|
69
69
|
|---|---|---|
|
|
70
70
|
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
|
71
71
|
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
|
72
|
-
| `form` |
|
|
72
|
+
| `form` | 否 | 表单值,**JSON 数组字符串**,不是普通对象;API 层非必填,但审批定义存在必填控件或用户需要提交表单值时必须传 |
|
|
73
73
|
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
|
74
74
|
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
|
75
75
|
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
|
76
|
-
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
|
77
|
-
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
|
78
76
|
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
|
79
77
|
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
|
80
78
|
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
|
81
79
|
|
|
82
80
|
### 4. 组装 `form`
|
|
83
81
|
|
|
84
|
-
`instances create --data.form`
|
|
82
|
+
`instances create --data.form` 是可选字段;传入时必须是一个 JSON 数组字符串。无表单或无需填写表单值的审批可省略 `form`,但只要审批定义包含需要提交的控件,就必须按控件结构组装后传入。组装原则:
|
|
85
83
|
|
|
86
84
|
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
|
87
85
|
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
|
@@ -173,7 +171,6 @@ lark-cli approval instances create \
|
|
|
173
171
|
}
|
|
174
172
|
]
|
|
175
173
|
}' \
|
|
176
|
-
--params '{"user_id_type":"open_id"}' \
|
|
177
174
|
--as user \
|
|
178
175
|
--yes
|
|
179
176
|
```
|
|
@@ -14,6 +14,9 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
|
|
14
14
|
# 只看某个审批定义下我发起的实例
|
|
15
15
|
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
|
16
16
|
|
|
17
|
+
# 按发起时间范围筛选(秒级时间戳)
|
|
18
|
+
lark-cli approval instances initiated --params '{"start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>","page_size":20}' --as user
|
|
19
|
+
|
|
17
20
|
# 使用 page_token 翻页
|
|
18
21
|
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
|
19
22
|
|
|
@@ -30,6 +33,8 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user --dr
|
|
|
30
33
|
|------|------|------|
|
|
31
34
|
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
|
32
35
|
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
|
36
|
+
| `start_timestamp` | 否 | 按发起时间筛选,时间范围开始值,秒级时间戳 |
|
|
37
|
+
| `end_timestamp` | 否 | 按发起时间筛选,时间范围结束值,秒级时间戳 |
|
|
33
38
|
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
|
34
39
|
| `page_size` | 否 | 分页大小 |
|
|
35
40
|
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
|
@@ -101,6 +106,7 @@ lark-cli approval instances initiated \
|
|
|
101
106
|
|
|
102
107
|
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
|
103
108
|
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
|
109
|
+
- **按时间排查时使用 `start_timestamp` / `end_timestamp`**:这两个值都是秒级时间戳,用于按发起时间缩小结果范围。
|
|
104
110
|
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
|
105
111
|
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
|
106
112
|
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
|
@@ -14,6 +14,9 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
|
|
14
14
|
# 查询已办审批
|
|
15
15
|
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
|
16
16
|
|
|
17
|
+
# 按任务时间范围筛选(秒级时间戳)
|
|
18
|
+
lark-cli approval tasks query --params '{"topic":"1","start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>"}' --as user
|
|
19
|
+
|
|
17
20
|
# 使用 page_token 翻页
|
|
18
21
|
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
|
19
22
|
|
|
@@ -28,6 +31,8 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
|
|
28
31
|
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
|
29
32
|
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
|
30
33
|
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
|
34
|
+
| `start_timestamp` | 否 | 按任务时间筛选,时间范围开始值,秒级时间戳 |
|
|
35
|
+
| `end_timestamp` | 否 | 按任务时间筛选,时间范围结束值,秒级时间戳 |
|
|
31
36
|
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
|
32
37
|
| `page_size` | 否 | 分页大小 |
|
|
33
38
|
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
|
@@ -67,10 +72,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
|
|
67
72
|
| `tasks[].summaries` | 表单摘要字段列表 |
|
|
68
73
|
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
|
69
74
|
| `tasks[].user_id` | 任务所属用户 ID |
|
|
75
|
+
| `tasks[].instance_external_id` | 三方审批实例 ID,仅第三方审批实例存在 |
|
|
76
|
+
| `tasks[].task_external_id` | 三方审批任务 ID,仅第三方审批任务存在 |
|
|
77
|
+
| `tasks[].link` | 三方审批跳转链接 |
|
|
70
78
|
|
|
71
79
|
## 使用建议
|
|
72
80
|
|
|
73
81
|
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
|
74
82
|
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
|
83
|
+
- 按时间排查任务时使用 `start_timestamp` / `end_timestamp` 缩小范围;这两个值都是秒级时间戳。
|
|
75
84
|
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
|
76
85
|
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
|
@@ -23,6 +23,12 @@ lark-cli approval tasks rollback \
|
|
|
23
23
|
--as user \
|
|
24
24
|
--yes
|
|
25
25
|
|
|
26
|
+
# 退回到发起节点(发起节点 ID 为 START)
|
|
27
|
+
lark-cli approval tasks rollback \
|
|
28
|
+
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["START"],"comment":"退回发起人补充材料"}' \
|
|
29
|
+
--as user \
|
|
30
|
+
--yes
|
|
31
|
+
|
|
26
32
|
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
|
27
33
|
lark-cli approval tasks rollback \
|
|
28
34
|
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
|
@@ -43,7 +49,7 @@ lark-cli approval tasks rollback \
|
|
|
43
49
|
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
|
44
50
|
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
|
45
51
|
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
|
46
|
-
| `node_ids` | 是 | 退回目标节点 ID
|
|
52
|
+
| `node_ids` | 是 | 退回目标节点 ID 数组;发起节点 ID 为 `START`;执行前应先确认这些节点确实可作为退回目标 |
|
|
47
53
|
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
|
48
54
|
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
|
49
55
|
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
|
@@ -75,7 +81,7 @@ lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' -
|
|
|
75
81
|
## 使用建议
|
|
76
82
|
|
|
77
83
|
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
|
78
|
-
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID
|
|
84
|
+
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组;退回发起节点时传 `START`。
|
|
79
85
|
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
|
80
86
|
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
|
81
87
|
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lark-base
|
|
3
|
-
version: 1.2.
|
|
3
|
+
version: 1.2.3
|
|
4
4
|
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
|
5
5
|
metadata:
|
|
6
6
|
requires:
|
|
@@ -104,6 +104,8 @@ metadata:
|
|
|
104
104
|
|
|
105
105
|
## 写入前置规则
|
|
106
106
|
|
|
107
|
+
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
|
|
108
|
+
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
|
|
107
109
|
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
|
108
110
|
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
|
109
111
|
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
|
@@ -134,6 +136,8 @@ metadata:
|
|
|
134
136
|
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API |
|
|
135
137
|
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 |
|
|
136
138
|
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |
|
|
139
|
+
| `Invalid discriminator value`(字段写入缺 `type`) | 按完整提交规则读取当前字段,只改目标内容后提交;不要只补 `type` 重试 |
|
|
140
|
+
| filter 报 `value of type array` / `Only string values` | 用 record/view 的 tuple `--filter-json`(非 `+data-query` 对象型),value 按字段 type 选标量或数组;见 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md) |
|
|
137
141
|
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
|
|
138
142
|
| formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 |
|
|
139
143
|
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
|
|
@@ -174,11 +174,13 @@ lark-cli base +view-set-filter \
|
|
|
174
174
|
|
|
175
175
|
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
|
176
176
|
- 优先传字段 id,不要依赖字段名。
|
|
177
|
+
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
|
177
178
|
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
|
178
179
|
|
|
179
180
|
## 7. 易错点
|
|
180
181
|
|
|
181
|
-
-
|
|
182
|
+
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
|
183
|
+
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
|
182
184
|
- `user` / `group_chat` / `link` 不要写成单个标量。
|
|
183
185
|
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
|
184
186
|
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lark-event
|
|
3
3
|
version: 1.0.0
|
|
4
|
-
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
|
4
|
+
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Approval status changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
|
5
5
|
metadata:
|
|
6
6
|
requires:
|
|
7
7
|
bins: ["lark-cli"]
|
|
@@ -147,6 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
|
|
147
147
|
|
|
148
148
|
| Topic | Reference | Coverage |
|
|
149
149
|
|------------|------------------------------------------------------------------------------|---|
|
|
150
|
+
| Approval | [`references/lark-event-approval.md`](references/lark-event-approval.md) | Catalog of 2 Approval EventKeys (`approval.instance.status_changed_v4`, `approval.task.status_changed_v4`) + optional/multi `subscription_type` pre-registration + user-auth subscription lifecycle + flat output field reference |
|
|
150
151
|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
|
|
151
152
|
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
|
|
152
153
|
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Approval Events
|
|
2
|
+
|
|
3
|
+
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
|
4
|
+
|
|
5
|
+
## Key catalog (2)
|
|
6
|
+
|
|
7
|
+
| EventKey | Purpose |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `approval.instance.status_changed_v4` | An approval instance status changed |
|
|
10
|
+
| `approval.task.status_changed_v4` | An approval task status changed |
|
|
11
|
+
|
|
12
|
+
Both keys use a **Custom schema**. The raw Lark schema 2.0 envelope is flattened: event metadata is exposed as `type`, `event_id`, and `timestamp`, while approval business fields are exposed at the top level.
|
|
13
|
+
|
|
14
|
+
Both keys carry a **PreConsume hook** that subscribes the current authorized user through the Approval subscription APIs before listening. The consumer intentionally does **not** unsubscribe on exit; the server-side Approval subscription relation remains until it is canceled outside `event consume`. These keys require `--as user`.
|
|
15
|
+
|
|
16
|
+
## Listener and subscription selection
|
|
17
|
+
|
|
18
|
+
At the raw CLI level, each `event consume` process accepts exactly one EventKey. `approval.instance.status_changed_v4` and `approval.task.status_changed_v4` have different output shapes, so listening to both still means two processes.
|
|
19
|
+
|
|
20
|
+
For Approval only, `subscription_type` is an optional setup param used by PreConsume to register server-side Approval subscription relations before the local listener starts. It is **not** an output field, a local event filter, or a local subscription identity. The pushed event does not say which subscription relation caused delivery, and one business event can match both relations; deduplicate with `event_id` when needed.
|
|
21
|
+
|
|
22
|
+
`subscription_type` may be omitted, a single value, a comma-separated list, or a JSON string array:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Omitted: register both INVOLVED_APPROVAL and MANAGED_APPROVAL for this EventKey
|
|
26
|
+
lark-cli event consume approval.instance.status_changed_v4 --as user
|
|
27
|
+
|
|
28
|
+
# Single relation
|
|
29
|
+
lark-cli event consume approval.instance.status_changed_v4 \
|
|
30
|
+
-p subscription_type=INVOLVED_APPROVAL \
|
|
31
|
+
--as user
|
|
32
|
+
|
|
33
|
+
# Explicit multi-relation registration for one local consumer
|
|
34
|
+
lark-cli event consume approval.task.status_changed_v4 \
|
|
35
|
+
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
|
36
|
+
--as user
|
|
37
|
+
|
|
38
|
+
# JSON array form; quote it for the shell
|
|
39
|
+
lark-cli event consume approval.task.status_changed_v4 \
|
|
40
|
+
-p 'subscription_type=["INVOLVED_APPROVAL","MANAGED_APPROVAL"]' \
|
|
41
|
+
--as user
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Value | Meaning |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `INVOLVED_APPROVAL` | Receive events where the current user is the approval requester or approver |
|
|
47
|
+
| `MANAGED_APPROVAL` | Receive events under approval definitions managed by the current user |
|
|
48
|
+
|
|
49
|
+
User-intent inference:
|
|
50
|
+
|
|
51
|
+
| User intent | EventKey(s) | `subscription_type` |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| Mentions approval instances, approval forms, approval order/status, or "instance status" | `approval.instance.status_changed_v4` | infer from relation words below |
|
|
54
|
+
| Mentions approval tasks, approval todo items, approver operations, or "task status" | `approval.task.status_changed_v4` | infer from relation words below |
|
|
55
|
+
| Says "approval status changes/events" without saying task vs instance | both EventKeys | infer from relation words below |
|
|
56
|
+
| Says "my approvals", "approvals involving me", "I requested/approved", "待我审批", "我发起/我参与" | requested EventKey(s) | `INVOLVED_APPROVAL` |
|
|
57
|
+
| Says "approvals I manage", "managed definitions", "definitions managed by me", "我管理的审批定义" | requested EventKey(s) | `MANAGED_APPROVAL` |
|
|
58
|
+
| Explicitly asks for both involved and managed, or says "all approval subscriptions" | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type`, or pass both values in one `-p` |
|
|
59
|
+
| Relation is ambiguous and the user wants broad coverage | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type` so PreConsume registers both |
|
|
60
|
+
|
|
61
|
+
If the user's wording omits the relation and broad listening is acceptable, omit `subscription_type`. Ask only when registering both relations would be materially harmful.
|
|
62
|
+
|
|
63
|
+
## Scopes & auth
|
|
64
|
+
|
|
65
|
+
| EventKey | Scope | Auth |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `approval.instance.status_changed_v4` | `approval:instance:read` | user |
|
|
68
|
+
| `approval.task.status_changed_v4` | `approval:task:read` | user |
|
|
69
|
+
|
|
70
|
+
## Subscription behavior
|
|
71
|
+
|
|
72
|
+
Startup calls the endpoint for the selected EventKey:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
POST /open-apis/approval/v4/instances/subscription
|
|
76
|
+
POST /open-apis/approval/v4/tasks/subscription
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For each resolved `subscription_type`, PreConsume sends one request body:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{"subscription_type":"INVOLVED_APPROVAL"}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If `subscription_type` is omitted, PreConsume sends two registration requests for that EventKey: one with `INVOLVED_APPROVAL`, then one with `MANAGED_APPROVAL`. If listening to both instance and task events, run two consumers; each consumer may omit `subscription_type` to register both relations for its own EventKey.
|
|
86
|
+
|
|
87
|
+
Do not start two consumers for the same Approval EventKey merely to split `INVOLVED_APPROVAL` and `MANAGED_APPROVAL`. The server push and flattened output are keyed by EventKey and cannot be distinguished by subscription relation.
|
|
88
|
+
|
|
89
|
+
Shutdown behavior:
|
|
90
|
+
|
|
91
|
+
`event consume` does not call the Approval unsubscribe APIs when it exits. This applies to graceful exit, Ctrl+C / SIGTERM, stdin EOF, `--timeout`, and `--max-events`.
|
|
92
|
+
|
|
93
|
+
To stop future delivery for a user, cancel the Approval subscription relation outside this consumer. The unsubscribe APIs are separate operations and are not called by `event consume`.
|
|
94
|
+
|
|
95
|
+
## Output fields
|
|
96
|
+
|
|
97
|
+
Common fields:
|
|
98
|
+
|
|
99
|
+
| Field | Type | Description |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `type` | string | Event type |
|
|
102
|
+
| `event_id` | string | Globally unique event ID; use for deduplication |
|
|
103
|
+
| `timestamp` | string (timestamp_ms) | Event delivery time in milliseconds, taken from `header.create_time` |
|
|
104
|
+
|
|
105
|
+
Instance event fields:
|
|
106
|
+
|
|
107
|
+
| Field | Type | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
|
110
|
+
| `instance_code` | string | Approval instance code |
|
|
111
|
+
| `external_id` | string | Third-party approval instance id, when present |
|
|
112
|
+
| `status` | string enum | `PENDING`, `APPROVED`, `REJECTED`, `CANCELED`, `DELETED`, `REVERTED`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
|
113
|
+
| `operate_time` | string (timestamp_ms) | Status change time |
|
|
114
|
+
| `start_user` | object | Instance starter user IDs, omitted when unavailable |
|
|
115
|
+
| `start_user.open_id` | string (open_id) | Instance starter open_id, when present |
|
|
116
|
+
| `start_user.union_id` | string (union_id) | Instance starter union_id, when present |
|
|
117
|
+
| `start_user.user_id` | string (user_id) | Instance starter tenant user_id, when present |
|
|
118
|
+
|
|
119
|
+
Task event fields:
|
|
120
|
+
|
|
121
|
+
| Field | Type | Description |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
|
124
|
+
| `instance_code` | string | Approval instance code |
|
|
125
|
+
| `task_id` | string | Approval task id |
|
|
126
|
+
| `external_id` | string | Third-party approval external id, when present |
|
|
127
|
+
| `task_external_id` | string | Third-party task external id, when emitted |
|
|
128
|
+
| `assigned_user` | object | Task assignee or operator user IDs, omitted for automatic flows without an operator |
|
|
129
|
+
| `assigned_user.open_id` | string (open_id) | Task assignee or operator open_id, when present |
|
|
130
|
+
| `assigned_user.union_id` | string (union_id) | Task assignee or operator union_id, when present |
|
|
131
|
+
| `assigned_user.user_id` | string (user_id) | Task assignee or operator tenant user_id, when present |
|
|
132
|
+
| `status` | string enum | `REVERTED`, `PENDING`, `APPROVED`, `REJECTED`, `TRANSFERRED`, `ROLLBACK`, `DONE`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
|
133
|
+
| `operate_time` | string (timestamp_ms) | Status change time |
|
|
134
|
+
|
|
135
|
+
## Examples
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Stream approval instance updates broadly; registers both involved and managed relations
|
|
139
|
+
lark-cli event consume approval.instance.status_changed_v4 \
|
|
140
|
+
--as user
|
|
141
|
+
|
|
142
|
+
# Stream approval instance updates only for approvals involving the current user
|
|
143
|
+
lark-cli event consume approval.instance.status_changed_v4 \
|
|
144
|
+
-p subscription_type=INVOLVED_APPROVAL \
|
|
145
|
+
--as user
|
|
146
|
+
|
|
147
|
+
# Stream approval task updates for definitions managed by the current user
|
|
148
|
+
lark-cli event consume approval.task.status_changed_v4 \
|
|
149
|
+
-p subscription_type=MANAGED_APPROVAL \
|
|
150
|
+
--as user
|
|
151
|
+
|
|
152
|
+
# Broad approval status listening:
|
|
153
|
+
# run both EventKeys as separate processes; omit subscription_type so each registers both relations.
|
|
154
|
+
lark-cli event consume approval.instance.status_changed_v4 \
|
|
155
|
+
--as user > approval-instance.ndjson &
|
|
156
|
+
lark-cli event consume approval.task.status_changed_v4 \
|
|
157
|
+
--as user > approval-task.ndjson &
|
|
158
|
+
wait
|
|
159
|
+
|
|
160
|
+
# Listen to both involved and managed task subscriptions with one local consumer.
|
|
161
|
+
lark-cli event consume approval.task.status_changed_v4 \
|
|
162
|
+
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
|
163
|
+
--as user > approval-task.ndjson
|
|
164
|
+
|
|
165
|
+
# Project a compact approval-task record
|
|
166
|
+
lark-cli event consume approval.task.status_changed_v4 \
|
|
167
|
+
-p subscription_type=INVOLVED_APPROVAL \
|
|
168
|
+
--as user \
|
|
169
|
+
--jq '{event_id, task_id, status, at: .operate_time}'
|
|
170
|
+
```
|
|
@@ -38,6 +38,8 @@ SXSD_ATTR_ALIASES = {
|
|
|
38
38
|
"fontColor": "color",
|
|
39
39
|
}
|
|
40
40
|
SERVER_FILLED_SXSD_ATTRS = {"id"}
|
|
41
|
+
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
|
42
|
+
DEFAULT_TABLE_ROW_HEIGHT = 37
|
|
41
43
|
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
|
|
42
44
|
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
|
|
43
45
|
|
|
@@ -88,6 +90,84 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
|
|
88
90
|
return int(value) if value.is_integer() else value
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
def sum_sizes(sizes: list[int | float]) -> int | float:
|
|
94
|
+
return sum(sizes)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_filled_size(size: int | float | None) -> bool:
|
|
98
|
+
return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
|
|
102
|
+
if not sizes:
|
|
103
|
+
return sizes
|
|
104
|
+
final_sizes = [
|
|
105
|
+
size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
|
|
106
|
+
for index, size in enumerate(sizes)
|
|
107
|
+
]
|
|
108
|
+
remaining_size = target_size - sum_sizes(final_sizes[:-1])
|
|
109
|
+
if remaining_size >= 1:
|
|
110
|
+
final_sizes[-1] = remaining_size
|
|
111
|
+
return final_sizes
|
|
112
|
+
|
|
113
|
+
size_to_redistribute = 1 - remaining_size
|
|
114
|
+
for index in range(len(final_sizes) - 2, -1, -1):
|
|
115
|
+
reduction = min(final_sizes[index] - 1, size_to_redistribute)
|
|
116
|
+
final_sizes[index] -= reduction
|
|
117
|
+
size_to_redistribute -= reduction
|
|
118
|
+
if size_to_redistribute == 0:
|
|
119
|
+
final_sizes[-1] = 1
|
|
120
|
+
return final_sizes
|
|
121
|
+
|
|
122
|
+
final_sizes[-1] = 1
|
|
123
|
+
return final_sizes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def solve_weighted_min_layout(
|
|
127
|
+
input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
filled_indexes: list[int] = []
|
|
130
|
+
empty_indexes: list[int] = []
|
|
131
|
+
base_sizes: list[int | float] = []
|
|
132
|
+
for index, size in enumerate(input_sizes):
|
|
133
|
+
if is_filled_size(size):
|
|
134
|
+
filled_indexes.append(index)
|
|
135
|
+
base_sizes.append(size)
|
|
136
|
+
else:
|
|
137
|
+
empty_indexes.append(index)
|
|
138
|
+
base_sizes.append(0)
|
|
139
|
+
filled_sum = sum_sizes(base_sizes)
|
|
140
|
+
|
|
141
|
+
if target_min_size is None:
|
|
142
|
+
final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
|
|
143
|
+
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
|
144
|
+
|
|
145
|
+
if not filled_indexes:
|
|
146
|
+
average_size = target_min_size / len(input_sizes)
|
|
147
|
+
final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
|
|
148
|
+
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
|
149
|
+
|
|
150
|
+
if empty_indexes:
|
|
151
|
+
remaining_size = target_min_size - filled_sum
|
|
152
|
+
final_sizes = [*base_sizes]
|
|
153
|
+
if remaining_size > 0:
|
|
154
|
+
average_size = remaining_size / len(empty_indexes)
|
|
155
|
+
empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
|
|
156
|
+
for index, empty_size in zip(empty_indexes, empty_sizes):
|
|
157
|
+
final_sizes[index] = empty_size
|
|
158
|
+
else:
|
|
159
|
+
for index in empty_indexes:
|
|
160
|
+
final_sizes[index] = default_size
|
|
161
|
+
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
|
162
|
+
|
|
163
|
+
ratio = max(1, target_min_size / filled_sum)
|
|
164
|
+
actual_size = max(target_min_size, filled_sum)
|
|
165
|
+
if ratio == 1:
|
|
166
|
+
return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
|
|
167
|
+
final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
|
|
168
|
+
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
|
|
169
|
+
|
|
170
|
+
|
|
91
171
|
def strip_xml(value: str) -> str:
|
|
92
172
|
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
|
|
93
173
|
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
|
@@ -515,8 +595,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
515
595
|
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
|
|
516
596
|
kind, attrs = match.group(1), match.group(2)
|
|
517
597
|
content = ""
|
|
518
|
-
if kind
|
|
519
|
-
close_index = slide_xml.find("</
|
|
598
|
+
if kind in {"shape", "table"}:
|
|
599
|
+
close_index = slide_xml.find(f"</{kind}>", match.end())
|
|
520
600
|
if close_index != -1:
|
|
521
601
|
content = slide_xml[match.end() : close_index]
|
|
522
602
|
|
|
@@ -525,6 +605,15 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
525
605
|
y = extract_numeric_attribute(attrs, "topLeftY")
|
|
526
606
|
width = extract_numeric_attribute(attrs, "width")
|
|
527
607
|
height = extract_numeric_attribute(attrs, "height")
|
|
608
|
+
rotation = extract_numeric_attribute(attrs, "rotation") or 0
|
|
609
|
+
table_layouts: dict[str, dict[str, Any] | None] = {}
|
|
610
|
+
if kind == "table":
|
|
611
|
+
width, table_layouts["width"] = resolve_table_dimension(
|
|
612
|
+
content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
|
|
613
|
+
)
|
|
614
|
+
height, table_layouts["height"] = resolve_table_dimension(
|
|
615
|
+
content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
|
|
616
|
+
)
|
|
528
617
|
if all(value is not None for value in [x, y, width, height]):
|
|
529
618
|
element = {
|
|
530
619
|
"id": element_id,
|
|
@@ -534,8 +623,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
534
623
|
"y": y,
|
|
535
624
|
"width": width,
|
|
536
625
|
"height": height,
|
|
626
|
+
"rotation": rotation,
|
|
537
627
|
"order": len(elements),
|
|
538
628
|
}
|
|
629
|
+
if kind == "table":
|
|
630
|
+
element.update(
|
|
631
|
+
{
|
|
632
|
+
"declared_width": extract_numeric_attribute(attrs, "width"),
|
|
633
|
+
"declared_height": extract_numeric_attribute(attrs, "height"),
|
|
634
|
+
"table_layouts": table_layouts,
|
|
635
|
+
}
|
|
636
|
+
)
|
|
539
637
|
if kind == "shape":
|
|
540
638
|
element.update(
|
|
541
639
|
{
|
|
@@ -867,11 +965,158 @@ def detect_whiteboard_external_overlaps(
|
|
|
867
965
|
return issues
|
|
868
966
|
|
|
869
967
|
|
|
968
|
+
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
|
969
|
+
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
|
970
|
+
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
|
971
|
+
return bbox
|
|
972
|
+
|
|
973
|
+
rotation = element["rotation"]
|
|
974
|
+
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
|
975
|
+
rotation = 0
|
|
976
|
+
rotation %= 360
|
|
977
|
+
if math.isclose(rotation, 0, abs_tol=1e-9):
|
|
978
|
+
return bbox
|
|
979
|
+
radians = math.radians(rotation)
|
|
980
|
+
sine = abs(math.sin(radians))
|
|
981
|
+
cosine = abs(math.cos(radians))
|
|
982
|
+
sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
|
|
983
|
+
cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
|
|
984
|
+
rotated_width = element["width"] * cosine + element["height"] * sine
|
|
985
|
+
rotated_height = element["width"] * sine + element["height"] * cosine
|
|
986
|
+
return {
|
|
987
|
+
"x": element["x"] - (rotated_width - element["width"]) / 2,
|
|
988
|
+
"y": element["y"] - (rotated_height - element["height"]) / 2,
|
|
989
|
+
"width": rotated_width,
|
|
990
|
+
"height": rotated_height,
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def detect_elements_out_of_canvas(
|
|
995
|
+
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
|
996
|
+
) -> list[dict[str, Any]]:
|
|
997
|
+
issues: list[dict[str, Any]] = []
|
|
998
|
+
for element in (
|
|
999
|
+
element
|
|
1000
|
+
for element in elements
|
|
1001
|
+
if element["kind"] in {"table", "chart"}
|
|
1002
|
+
or (element["kind"] == "shape" and element["type"] == "text")
|
|
1003
|
+
):
|
|
1004
|
+
bbox = element_canvas_bbox(element)
|
|
1005
|
+
overflow = {
|
|
1006
|
+
"left": max(-bbox["x"], 0),
|
|
1007
|
+
"top": max(-bbox["y"], 0),
|
|
1008
|
+
"right": max(bbox["x"] + bbox["width"] - slide_width, 0),
|
|
1009
|
+
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
|
|
1010
|
+
}
|
|
1011
|
+
overflow_details = [
|
|
1012
|
+
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
|
|
1013
|
+
]
|
|
1014
|
+
if not overflow_details:
|
|
1015
|
+
continue
|
|
1016
|
+
issues.append(
|
|
1017
|
+
{
|
|
1018
|
+
"level": "error",
|
|
1019
|
+
"code": f'{element["kind"]}_out_of_canvas',
|
|
1020
|
+
"elements": [element["id"]],
|
|
1021
|
+
"canvas": {"width": slide_width, "height": slide_height},
|
|
1022
|
+
"bbox": bbox,
|
|
1023
|
+
"overflow": overflow,
|
|
1024
|
+
"message": (
|
|
1025
|
+
f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
|
|
1026
|
+
f'({", ".join(overflow_details)})'
|
|
1027
|
+
),
|
|
1028
|
+
"hint": (
|
|
1029
|
+
"Move the table inside the canvas, reduce table.width/table.height, or split the table across "
|
|
1030
|
+
"slides."
|
|
1031
|
+
if element["kind"] == "table"
|
|
1032
|
+
else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
|
|
1033
|
+
),
|
|
1034
|
+
}
|
|
1035
|
+
)
|
|
1036
|
+
return issues
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
|
|
1040
|
+
sizes: list[int | float | None] = []
|
|
1041
|
+
for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
|
|
1042
|
+
attrs = match.group(1)
|
|
1043
|
+
span = extract_numeric_attribute(attrs, "span") or 1
|
|
1044
|
+
span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
|
|
1045
|
+
sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
|
|
1046
|
+
return sizes
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
|
|
1050
|
+
return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def resolve_table_dimension(
|
|
1054
|
+
table_xml: str,
|
|
1055
|
+
declared_size: int | float | None,
|
|
1056
|
+
extract_sizes: Any,
|
|
1057
|
+
default_size: int | float,
|
|
1058
|
+
) -> tuple[int | float | None, dict[str, Any] | None]:
|
|
1059
|
+
input_sizes = extract_sizes(table_xml)
|
|
1060
|
+
if not input_sizes:
|
|
1061
|
+
return declared_size, None
|
|
1062
|
+
layout = solve_weighted_min_layout(
|
|
1063
|
+
input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
|
|
1064
|
+
)
|
|
1065
|
+
return layout["actual_size"], layout
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def format_size(size: int | float) -> str:
|
|
1069
|
+
return f"{size:g}"
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1073
|
+
issues: list[dict[str, Any]] = []
|
|
1074
|
+
dimensions = {
|
|
1075
|
+
"width": ("col", "column widths"),
|
|
1076
|
+
"height": ("tr", "row heights"),
|
|
1077
|
+
}
|
|
1078
|
+
for table in (element for element in elements if element["kind"] == "table"):
|
|
1079
|
+
for dimension, (child_tag, child_description) in dimensions.items():
|
|
1080
|
+
target_size = table[f"declared_{dimension}"]
|
|
1081
|
+
if not is_filled_size(target_size):
|
|
1082
|
+
continue
|
|
1083
|
+
layout = table["table_layouts"][dimension]
|
|
1084
|
+
if layout is None:
|
|
1085
|
+
continue
|
|
1086
|
+
actual_size = layout["actual_size"]
|
|
1087
|
+
if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
|
|
1088
|
+
continue
|
|
1089
|
+
issues.append(
|
|
1090
|
+
{
|
|
1091
|
+
"level": "info",
|
|
1092
|
+
"code": "table_resolved_size_mismatch",
|
|
1093
|
+
"elements": [table["id"]],
|
|
1094
|
+
"dimension": dimension,
|
|
1095
|
+
"declared_size": target_size,
|
|
1096
|
+
"resolved_size": actual_size,
|
|
1097
|
+
"resolved_sizes": layout["final_sizes"],
|
|
1098
|
+
"message": (
|
|
1099
|
+
f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
|
|
1100
|
+
f"{child_description} resolve to {format_size(actual_size)}px"
|
|
1101
|
+
),
|
|
1102
|
+
"hint": (
|
|
1103
|
+
f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
|
|
1104
|
+
f"so their resolved total matches {format_size(target_size)}px."
|
|
1105
|
+
),
|
|
1106
|
+
}
|
|
1107
|
+
)
|
|
1108
|
+
return issues
|
|
1109
|
+
|
|
1110
|
+
|
|
870
1111
|
def lint_slide(
|
|
871
1112
|
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
|
872
1113
|
) -> dict[str, Any]:
|
|
873
1114
|
elements = extract_elements(slide_xml)
|
|
874
|
-
issues: list[dict[str, Any]] =
|
|
1115
|
+
issues: list[dict[str, Any]] = [
|
|
1116
|
+
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
|
|
1117
|
+
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
|
|
1118
|
+
*detect_table_layout_size_mismatches(elements),
|
|
1119
|
+
]
|
|
875
1120
|
|
|
876
1121
|
for index, left in enumerate(elements):
|
|
877
1122
|
for right in elements[index + 1 :]:
|
|
@@ -896,7 +1141,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
896
1141
|
return {
|
|
897
1142
|
"file": source_path,
|
|
898
1143
|
"slide_size": {"width": 960, "height": 540},
|
|
899
|
-
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0},
|
|
1144
|
+
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
|
|
900
1145
|
"issues": [xml_error],
|
|
901
1146
|
"slides": [],
|
|
902
1147
|
}
|
|
@@ -908,10 +1153,16 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
908
1153
|
if namespace_issues:
|
|
909
1154
|
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
|
|
910
1155
|
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
|
1156
|
+
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
|
911
1157
|
return {
|
|
912
1158
|
"file": source_path,
|
|
913
1159
|
"slide_size": {"width": 960, "height": 540},
|
|
914
|
-
"summary": {
|
|
1160
|
+
"summary": {
|
|
1161
|
+
"slide_count": 0,
|
|
1162
|
+
"error_count": error_count,
|
|
1163
|
+
"warning_count": warning_count,
|
|
1164
|
+
"info_count": info_count,
|
|
1165
|
+
},
|
|
915
1166
|
"issues": top_level_issues,
|
|
916
1167
|
"slides": [],
|
|
917
1168
|
}
|
|
@@ -924,10 +1175,17 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
924
1175
|
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
|
925
1176
|
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
|
926
1177
|
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
|
1178
|
+
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
|
1179
|
+
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
|
|
927
1180
|
result = {
|
|
928
1181
|
"file": source_path,
|
|
929
1182
|
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
|
930
|
-
"summary": {
|
|
1183
|
+
"summary": {
|
|
1184
|
+
"slide_count": len(slides),
|
|
1185
|
+
"error_count": error_count,
|
|
1186
|
+
"warning_count": warning_count,
|
|
1187
|
+
"info_count": info_count,
|
|
1188
|
+
},
|
|
931
1189
|
"slides": slides,
|
|
932
1190
|
}
|
|
933
1191
|
if top_level_issues:
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
5
9
|
import unittest
|
|
10
|
+
from pathlib import Path
|
|
6
11
|
|
|
7
12
|
import xml_text_overlap_lint
|
|
8
13
|
|
|
@@ -212,7 +217,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
212
217
|
)
|
|
213
218
|
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
|
|
214
219
|
self.assertEqual(result["summary"]["slide_count"], 1)
|
|
215
|
-
self.assertEqual(result["summary"]["error_count"],
|
|
220
|
+
self.assertEqual(result["summary"]["error_count"], 1)
|
|
221
|
+
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
|
216
222
|
|
|
217
223
|
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
|
|
218
224
|
result = xml_text_overlap_lint.lint_xml(
|
|
@@ -596,7 +602,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
596
602
|
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
|
|
597
603
|
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
|
|
598
604
|
|
|
599
|
-
def
|
|
605
|
+
def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None:
|
|
600
606
|
result = xml_text_overlap_lint.lint_xml(
|
|
601
607
|
"""
|
|
602
608
|
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
@@ -613,8 +619,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
613
619
|
</presentation>
|
|
614
620
|
"""
|
|
615
621
|
)
|
|
616
|
-
|
|
622
|
+
issue = result["slides"][0]["issues"][0]
|
|
623
|
+
self.assertEqual(result["summary"]["error_count"], 1)
|
|
617
624
|
self.assertEqual(result["summary"]["warning_count"], 0)
|
|
625
|
+
self.assertEqual(issue["code"], "shape_out_of_canvas")
|
|
626
|
+
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
|
|
618
627
|
|
|
619
628
|
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
|
|
620
629
|
result = xml_text_overlap_lint.lint_xml(
|
|
@@ -669,7 +678,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
669
678
|
self.assertEqual(elements[1]["fontSize"], 28)
|
|
670
679
|
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
|
|
671
680
|
|
|
672
|
-
def
|
|
681
|
+
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
|
|
673
682
|
result = xml_text_overlap_lint.lint_xml(
|
|
674
683
|
"""
|
|
675
684
|
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
@@ -683,7 +692,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
683
692
|
)
|
|
684
693
|
self.assertEqual(result["summary"]["error_count"], 0)
|
|
685
694
|
|
|
686
|
-
def
|
|
695
|
+
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
|
|
687
696
|
result = xml_text_overlap_lint.lint_xml(
|
|
688
697
|
"""
|
|
689
698
|
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
@@ -698,7 +707,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
698
707
|
)
|
|
699
708
|
self.assertEqual(result["summary"]["error_count"], 0)
|
|
700
709
|
|
|
701
|
-
def
|
|
710
|
+
def test_lint_xml_allows_full_bleed_images(self) -> None:
|
|
702
711
|
result = xml_text_overlap_lint.lint_xml(
|
|
703
712
|
"""
|
|
704
713
|
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
@@ -712,6 +721,339 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
|
|
712
721
|
)
|
|
713
722
|
self.assertEqual(result["summary"]["error_count"], 0)
|
|
714
723
|
|
|
724
|
+
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
|
|
725
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
726
|
+
"""
|
|
727
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
728
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
729
|
+
<data>
|
|
730
|
+
<shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
|
|
731
|
+
<img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
|
|
732
|
+
<chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
|
|
733
|
+
<whiteboard id="outside-whiteboard" topLeftX="100" topLeftY="500" width="100" height="100"/>
|
|
734
|
+
</data>
|
|
735
|
+
</slide>
|
|
736
|
+
</presentation>
|
|
737
|
+
"""
|
|
738
|
+
)
|
|
739
|
+
issues = result["slides"][0]["issues"]
|
|
740
|
+
self.assertEqual(result["summary"]["error_count"], 2)
|
|
741
|
+
self.assertEqual(
|
|
742
|
+
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
|
|
743
|
+
[
|
|
744
|
+
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
|
|
745
|
+
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
|
|
746
|
+
],
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
|
|
750
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
751
|
+
"""
|
|
752
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
753
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
754
|
+
<data>
|
|
755
|
+
<shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
|
|
756
|
+
<chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
|
|
757
|
+
</data>
|
|
758
|
+
</slide>
|
|
759
|
+
</presentation>
|
|
760
|
+
"""
|
|
761
|
+
)
|
|
762
|
+
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
|
763
|
+
self.assertEqual(result["summary"]["error_count"], 2)
|
|
764
|
+
self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
|
|
765
|
+
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
|
|
766
|
+
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
|
|
767
|
+
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
|
|
768
|
+
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
|
|
769
|
+
|
|
770
|
+
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
|
|
771
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
772
|
+
"""
|
|
773
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
774
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
775
|
+
<data>
|
|
776
|
+
<shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
|
|
777
|
+
<shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
|
|
778
|
+
<chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan"/>
|
|
779
|
+
</data>
|
|
780
|
+
</slide>
|
|
781
|
+
</presentation>
|
|
782
|
+
"""
|
|
783
|
+
)
|
|
784
|
+
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
|
785
|
+
self.assertEqual(result["summary"]["error_count"], 3)
|
|
786
|
+
self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0})
|
|
787
|
+
self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0})
|
|
788
|
+
self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0})
|
|
789
|
+
|
|
790
|
+
def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
|
|
791
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
792
|
+
"""
|
|
793
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
794
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
795
|
+
<data>
|
|
796
|
+
<table id="score-table" topLeftX="54" topLeftY="238" width="414" height="385">
|
|
797
|
+
<tr><td><content><p>Score</p></content></td></tr>
|
|
798
|
+
</table>
|
|
799
|
+
</data>
|
|
800
|
+
</slide>
|
|
801
|
+
</presentation>
|
|
802
|
+
"""
|
|
803
|
+
)
|
|
804
|
+
issue = result["slides"][0]["issues"][0]
|
|
805
|
+
self.assertEqual(result["summary"]["error_count"], 1)
|
|
806
|
+
self.assertEqual(issue["code"], "table_out_of_canvas")
|
|
807
|
+
self.assertEqual(issue["elements"], ["score-table"])
|
|
808
|
+
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 0, "bottom": 83})
|
|
809
|
+
self.assertEqual(issue["bbox"], {"x": 54, "y": 238, "width": 414, "height": 385})
|
|
810
|
+
|
|
811
|
+
def test_lint_xml_reports_table_right_overflow_from_declared_bounds(self) -> None:
|
|
812
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
813
|
+
"""
|
|
814
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
815
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
816
|
+
<data>
|
|
817
|
+
<table id="wide-table" topLeftX="850" topLeftY="80" width="180" height="120">
|
|
818
|
+
<tr><td><content><p>Score</p></content></td></tr>
|
|
819
|
+
</table>
|
|
820
|
+
</data>
|
|
821
|
+
</slide>
|
|
822
|
+
</presentation>
|
|
823
|
+
"""
|
|
824
|
+
)
|
|
825
|
+
issue = result["slides"][0]["issues"][0]
|
|
826
|
+
self.assertEqual(result["summary"]["error_count"], 1)
|
|
827
|
+
self.assertEqual(issue["code"], "table_out_of_canvas")
|
|
828
|
+
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 70, "bottom": 0})
|
|
829
|
+
|
|
830
|
+
def test_lint_xml_allows_table_with_declared_bounds_inside_canvas(self) -> None:
|
|
831
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
832
|
+
"""
|
|
833
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
834
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
835
|
+
<data>
|
|
836
|
+
<table id="inside-table" topLeftX="40" topLeftY="120" width="880" height="360">
|
|
837
|
+
<tr><td><content><p>Score</p></content></td></tr>
|
|
838
|
+
</table>
|
|
839
|
+
</data>
|
|
840
|
+
</slide>
|
|
841
|
+
</presentation>
|
|
842
|
+
"""
|
|
843
|
+
)
|
|
844
|
+
self.assertEqual(result["summary"]["error_count"], 0)
|
|
845
|
+
self.assertEqual(result["summary"]["warning_count"], 0)
|
|
846
|
+
|
|
847
|
+
def test_lint_xml_reports_resolved_table_bounds_when_declared_sizes_are_missing(self) -> None:
|
|
848
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
849
|
+
"""
|
|
850
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
851
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
852
|
+
<data>
|
|
853
|
+
<table id="implicit-size-table" topLeftX="850" topLeftY="480">
|
|
854
|
+
<colgroup><col/><col/></colgroup>
|
|
855
|
+
<tr><td/><td/></tr>
|
|
856
|
+
<tr><td/><td/></tr>
|
|
857
|
+
</table>
|
|
858
|
+
</data>
|
|
859
|
+
</slide>
|
|
860
|
+
</presentation>
|
|
861
|
+
"""
|
|
862
|
+
)
|
|
863
|
+
issue = result["slides"][0]["issues"][0]
|
|
864
|
+
self.assertEqual(result["summary"]["error_count"], 1)
|
|
865
|
+
self.assertEqual(issue["code"], "table_out_of_canvas")
|
|
866
|
+
self.assertEqual(issue["bbox"], {"x": 850, "y": 480, "width": 220, "height": 74})
|
|
867
|
+
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 110, "bottom": 14})
|
|
868
|
+
|
|
869
|
+
def test_lint_xml_uses_resolved_table_bounds_for_canvas_validation(self) -> None:
|
|
870
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
871
|
+
"""
|
|
872
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
873
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
874
|
+
<data>
|
|
875
|
+
<table id="resolved-overflow-table" topLeftX="800" topLeftY="80" width="100" height="40">
|
|
876
|
+
<colgroup><col width="100"/><col width="100"/></colgroup>
|
|
877
|
+
<tr height="40"><td/><td/></tr>
|
|
878
|
+
</table>
|
|
879
|
+
</data>
|
|
880
|
+
</slide>
|
|
881
|
+
</presentation>
|
|
882
|
+
"""
|
|
883
|
+
)
|
|
884
|
+
issues = result["slides"][0]["issues"]
|
|
885
|
+
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
|
|
886
|
+
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
|
|
887
|
+
self.assertEqual(result["summary"]["error_count"], 1)
|
|
888
|
+
self.assertEqual(canvas_issue["bbox"], {"x": 800, "y": 80, "width": 200, "height": 40})
|
|
889
|
+
self.assertEqual(canvas_issue["overflow"]["right"], 40)
|
|
890
|
+
self.assertEqual(mismatch_issue["dimension"], "width")
|
|
891
|
+
self.assertEqual(mismatch_issue["resolved_size"], canvas_issue["bbox"]["width"])
|
|
892
|
+
|
|
893
|
+
def test_lint_xml_uses_the_same_anonymous_table_id_for_all_table_diagnostics(self) -> None:
|
|
894
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
895
|
+
"""
|
|
896
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
897
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
898
|
+
<data>
|
|
899
|
+
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="200" height="40"/>
|
|
900
|
+
<img id="logo" src="token" topLeftX="40" topLeftY="100" width="40" height="40"/>
|
|
901
|
+
<table topLeftX="900" topLeftY="80" width="100" height="40">
|
|
902
|
+
<colgroup><col width="100"/><col width="100"/></colgroup>
|
|
903
|
+
<tr height="40"><td/><td/></tr>
|
|
904
|
+
</table>
|
|
905
|
+
</data>
|
|
906
|
+
</slide>
|
|
907
|
+
</presentation>
|
|
908
|
+
"""
|
|
909
|
+
)
|
|
910
|
+
issues = result["slides"][0]["issues"]
|
|
911
|
+
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
|
|
912
|
+
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
|
|
913
|
+
self.assertEqual(canvas_issue["elements"], ["table-3"])
|
|
914
|
+
self.assertEqual(mismatch_issue["elements"], ["table-3"])
|
|
915
|
+
|
|
916
|
+
def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_declared(self) -> None:
|
|
917
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
918
|
+
"""
|
|
919
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
920
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
921
|
+
<data>
|
|
922
|
+
<table id="size-mismatch" topLeftX="40" topLeftY="120" width="200" height="80">
|
|
923
|
+
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
|
|
924
|
+
<tr height="40"><td/><td/><td/></tr>
|
|
925
|
+
<tr height="60"><td/><td/><td/></tr>
|
|
926
|
+
</table>
|
|
927
|
+
</data>
|
|
928
|
+
</slide>
|
|
929
|
+
</presentation>
|
|
930
|
+
"""
|
|
931
|
+
)
|
|
932
|
+
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
|
|
933
|
+
self.assertEqual(result["summary"]["error_count"], 0)
|
|
934
|
+
self.assertEqual(result["summary"]["warning_count"], 0)
|
|
935
|
+
self.assertEqual(result["summary"]["info_count"], 2)
|
|
936
|
+
self.assertEqual(issues_by_dimension["width"]["level"], "info")
|
|
937
|
+
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
|
|
938
|
+
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
|
|
939
|
+
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
|
|
940
|
+
self.assertEqual(issues_by_dimension["height"]["resolved_sizes"], [40, 60])
|
|
941
|
+
self.assertEqual(issues_by_dimension["height"]["resolved_size"], 100)
|
|
942
|
+
|
|
943
|
+
def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactly(self) -> None:
|
|
944
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
945
|
+
"""
|
|
946
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
947
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
948
|
+
<data>
|
|
949
|
+
<table id="size-match" topLeftX="40" topLeftY="120" width="300" height="100">
|
|
950
|
+
<colgroup><col width="100"/><col/></colgroup>
|
|
951
|
+
<tr height="40"><td/><td/></tr>
|
|
952
|
+
<tr><td/><td/></tr>
|
|
953
|
+
</table>
|
|
954
|
+
</data>
|
|
955
|
+
</slide>
|
|
956
|
+
</presentation>
|
|
957
|
+
"""
|
|
958
|
+
)
|
|
959
|
+
self.assertEqual(result["summary"]["error_count"], 0)
|
|
960
|
+
self.assertEqual(result["summary"]["warning_count"], 0)
|
|
961
|
+
self.assertEqual(result["summary"]["info_count"], 0)
|
|
962
|
+
self.assertEqual(result["slides"][0]["issues"], [])
|
|
963
|
+
|
|
964
|
+
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
|
|
965
|
+
result = xml_text_overlap_lint.lint_xml(
|
|
966
|
+
"""
|
|
967
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
968
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
|
969
|
+
<data>
|
|
970
|
+
<table id="narrow-table" topLeftX="40" topLeftY="120" width="1">
|
|
971
|
+
<colgroup><col/><col/></colgroup>
|
|
972
|
+
<tr><td/><td/></tr>
|
|
973
|
+
</table>
|
|
974
|
+
</data>
|
|
975
|
+
</slide>
|
|
976
|
+
</presentation>
|
|
977
|
+
"""
|
|
978
|
+
)
|
|
979
|
+
issue = result["slides"][0]["issues"][0]
|
|
980
|
+
self.assertEqual(issue["dimension"], "width")
|
|
981
|
+
self.assertEqual(issue["resolved_sizes"], [1, 1])
|
|
982
|
+
self.assertEqual(issue["resolved_size"], 2)
|
|
983
|
+
|
|
984
|
+
def test_fill_last_size_gap_preserves_target_when_positive_sizes_are_possible(self) -> None:
|
|
985
|
+
final_sizes = xml_text_overlap_lint.fill_last_size_gap([10, 10], 3)
|
|
986
|
+
self.assertEqual(final_sizes, [2, 1])
|
|
987
|
+
self.assertEqual(sum(final_sizes), 3)
|
|
988
|
+
|
|
989
|
+
def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) -> None:
|
|
990
|
+
cases = {
|
|
991
|
+
"target-exact": (
|
|
992
|
+
"""
|
|
993
|
+
<table topLeftX="40" topLeftY="120" width="360" height="150">
|
|
994
|
+
<colgroup><col width="100"/><col width="200"/></colgroup>
|
|
995
|
+
<tr height="40"><td/><td/></tr><tr height="60"><td/><td/></tr>
|
|
996
|
+
</table>
|
|
997
|
+
""",
|
|
998
|
+
0,
|
|
999
|
+
),
|
|
1000
|
+
"declared-size-exceeds-target": (
|
|
1001
|
+
"""
|
|
1002
|
+
<table topLeftX="40" topLeftY="120" width="200" height="80">
|
|
1003
|
+
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
|
|
1004
|
+
<tr height="40"><td/><td/><td/></tr><tr height="60"><td/><td/><td/></tr>
|
|
1005
|
+
</table>
|
|
1006
|
+
""",
|
|
1007
|
+
2,
|
|
1008
|
+
),
|
|
1009
|
+
"remaining-space-insufficient": (
|
|
1010
|
+
"""
|
|
1011
|
+
<table topLeftX="40" topLeftY="120" width="80" height="30">
|
|
1012
|
+
<colgroup><col width="80"/><col/></colgroup>
|
|
1013
|
+
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
|
|
1014
|
+
</table>
|
|
1015
|
+
""",
|
|
1016
|
+
2,
|
|
1017
|
+
),
|
|
1018
|
+
"no-target-size": (
|
|
1019
|
+
"""
|
|
1020
|
+
<table topLeftX="40" topLeftY="120">
|
|
1021
|
+
<colgroup><col width="80"/><col/></colgroup>
|
|
1022
|
+
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
|
|
1023
|
+
</table>
|
|
1024
|
+
""",
|
|
1025
|
+
0,
|
|
1026
|
+
),
|
|
1027
|
+
}
|
|
1028
|
+
script_path = Path(xml_text_overlap_lint.__file__).resolve()
|
|
1029
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
1030
|
+
for name, (table_xml, expected_info_count) in cases.items():
|
|
1031
|
+
with self.subTest(case=name):
|
|
1032
|
+
input_path = Path(temp_dir) / f"{name}.xml"
|
|
1033
|
+
input_path.write_text(
|
|
1034
|
+
f"""
|
|
1035
|
+
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
|
1036
|
+
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>{table_xml}</data></slide>
|
|
1037
|
+
</presentation>
|
|
1038
|
+
""",
|
|
1039
|
+
encoding="utf-8",
|
|
1040
|
+
)
|
|
1041
|
+
completed = subprocess.run(
|
|
1042
|
+
[sys.executable, str(script_path), "--input", str(input_path)],
|
|
1043
|
+
capture_output=True,
|
|
1044
|
+
check=False,
|
|
1045
|
+
text=True,
|
|
1046
|
+
)
|
|
1047
|
+
result = json.loads(completed.stdout)
|
|
1048
|
+
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
1049
|
+
self.assertEqual(result["summary"]["error_count"], 0)
|
|
1050
|
+
self.assertEqual(result["summary"]["warning_count"], 0)
|
|
1051
|
+
self.assertEqual(result["summary"]["info_count"], expected_info_count)
|
|
1052
|
+
self.assertTrue(
|
|
1053
|
+
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
|
|
1054
|
+
result["slides"][0]["issues"],
|
|
1055
|
+
)
|
|
1056
|
+
|
|
715
1057
|
def test_lint_xml_warns_for_whiteboard_external_boundary_overlap(self) -> None:
|
|
716
1058
|
result = xml_text_overlap_lint.lint_xml(
|
|
717
1059
|
"""
|
package/skills/lark-vc/SKILL.md
CHANGED
|
@@ -56,7 +56,7 @@ lark-cli vc +search --query "站会" --start <start_time> --end <end_time>
|
|
|
56
56
|
|
|
57
57
|
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
|
58
58
|
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结、待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`)**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。
|
|
59
|
-
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token
|
|
59
|
+
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。妙记带有**原始会议录制视频**,会后**不会自动授权给参会人**,需管理员授权或参会人主动申请;而智能纪要及其逐字稿会后自动授权给参会人。
|
|
60
60
|
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
|
61
61
|
- **用户会议纪要(MeetingNotes)**:用户主动绑定到日程的纪要文档,对应 `meeting_note`。需先通过 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 由 `event_id` 获取。
|
|
62
62
|
- **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。
|
|
@@ -65,12 +65,15 @@ lark-cli vc +search --query "站会" --start <start_time> --end <end_time>
|
|
|
65
65
|
|
|
66
66
|
| 用户意图 | 必须读取的产物 | 禁止 |
|
|
67
67
|
|---------|-------------|------|
|
|
68
|
-
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 为降低 token 消耗,非必须不得获取 AI
|
|
69
|
-
| 查看待办/章节 | AI 纪要(`note_doc_token
|
|
68
|
+
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 为降低 token 消耗,非必须不得获取 AI 纪要。必须使用原始对话记录(按下方逐字稿路由取得),基于原始对话独立分析。两类产物都存在且用户未指定时,默认用智能纪要的逐字稿;用户明确要妙记时才用妙记文字记录(Transcript) | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
|
|
69
|
+
| 查看待办/章节 | 默认 AI 纪要(`note_doc_token`);仅存在妙记或用户明确要妙记时用妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
|
|
70
70
|
| 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — |
|
|
71
71
|
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token`) | — |
|
|
72
72
|
| 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得) | — |
|
|
73
73
|
|
|
74
|
+
> **智能纪要 vs 妙记的选择规则**(总结/待办/逐字稿等重复产物通用):只存在一类 → 用存在的那类;两类都存在且用户明确指定(如"看妙记逐字稿")→ **语义指向哪个走哪个,不要改道**;两类都存在但用户未指定 → **默认智能纪要及其逐字稿**(会后自动授权给参会人,访问门槛低于含原始录制视频、需申请授权的妙记)。完整说明见 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md) 的「产物选择决策」。
|
|
75
|
+
|
|
76
|
+
|
|
74
77
|
> **逐字稿路由**:先用 `vc +detail` 拿到 `note_id`,再 [`note +detail`](../lark-note/SKILL.md) 看 `note_display_type`,**不要只看 `verbatim_doc_token` 是否为空**。具体路由以 [lark-note](../lark-note/SKILL.md) 的 `note_display_type` 规则为准。
|
|
75
78
|
>
|
|
76
79
|
> **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)— **仅 `note_display_type=normal` 时是可读的独立文档**;`unified` 纪要的逐字稿用 `note +transcript --note-id <note_id>` 拉取(见下方 [Note 域](#note-域)) |
|
|
31
31
|
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
|
|
32
32
|
|
|
33
|
+
> **授权特性**:智能纪要总结文档及其逐字稿文档(总结文档尾部会挂逐字稿链接与会中投屏共享文档链接)在会后**自动授权给参会人**,参会人通常可直接读取,无需额外申请。
|
|
34
|
+
|
|
33
35
|
此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_note` 字段。这是用户主动绑定到日程的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 [`calendar +meeting --event-ids`](../../lark-calendar/references/lark-calendar-meeting.md) 路径返回。
|
|
34
36
|
|
|
35
37
|
#### 链路二:开启「录制」
|
|
@@ -43,6 +45,8 @@
|
|
|
43
45
|
| Chapter(章节) | 按讨论话题划分的核心内容摘要 |
|
|
44
46
|
| Transcript(文字记录) | 整场会议最原始的逐人发言记录 |
|
|
45
47
|
|
|
48
|
+
> **授权特性**:妙记带有**原始会议录制视频**,会后**不会自动授权给参会人**,需管理员主动授权或参会人主动申请后才能读取(含其 Summary/Todo/Chapter/Transcript 等产物)。因此当同一场会议既有智能纪要又有妙记时,参会人访问**智能纪要及其逐字稿**的门槛通常低于妙记。
|
|
49
|
+
|
|
46
50
|
#### 两条链路的独立性
|
|
47
51
|
|
|
48
52
|
- 智能纪要(AI 总结链路)和妙记(录制链路)**相互独立、互不影响**。
|
|
@@ -54,7 +58,11 @@
|
|
|
54
58
|
> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版,不是独立提炼。
|
|
55
59
|
> - **用户要求查看待办或章节时** → **应参考 AI 产物的待办和章节**,因为 AI 产物的待办更友好(包含提出人和负责人),章节按话题划分更结构化。
|
|
56
60
|
> - **用户只想直接看 AI 总结结果** → 使用 AI 产物的总结。
|
|
57
|
-
> -
|
|
61
|
+
> - **智能纪要 vs 妙记的选择规则**(适用于总结、待办、逐字稿等重复产物,含逐字稿/原始记录):
|
|
62
|
+
> - **只存在一类产物** → 用存在的那一类。
|
|
63
|
+
> - **两类都存在、用户明确指定了其中一类**(如"看妙记的逐字稿""用妙记总结")→ **语义指向哪个就走哪个链路,不要自作主张改道**。
|
|
64
|
+
> - **两类都存在、用户未指定** → **默认用智能纪要及其逐字稿**(智能纪要及逐字稿会后自动授权给参会人,访问门槛更低;妙记含原始录制视频、不自动授权,需申请)。
|
|
65
|
+
|
|
58
66
|
|
|
59
67
|
#### 逐字稿与文字记录的格式
|
|
60
68
|
|
|
@@ -92,7 +92,7 @@ metadata:
|
|
|
92
92
|
### 3. 发送会中文本或会中表情(写操作)
|
|
93
93
|
|
|
94
94
|
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`。
|
|
95
|
-
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no`
|
|
95
|
+
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。发消息只需 `meeting_id`,不要先查 `+detail`。
|
|
96
96
|
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`。
|
|
97
97
|
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type`。`--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
|
|
98
98
|
5. 支持普通 Feishu reaction emoji(如 `LOVE`、`SMILE`、`THUMBSUP`)和 4 个 VC 反馈 key(`VC_CanNotSee`、`VC_NoSound`、`VC_LooksGood`、`VC_SoundsClear`)。
|