@amaster.ai/pi-lark 0.1.2-beta.69 → 0.1.2-beta.71

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.
Files changed (46) hide show
  1. package/package.json +2 -2
  2. package/skills/lark-apps/references/lark-apps-local-dev.md +1 -1
  3. package/skills/lark-base/references/lark-base-app.md +2 -2
  4. package/skills/lark-base/references/lark-base-workflow-schema.md +68 -0
  5. package/skills/lark-base/references/lark-base-workflow.md +99 -3
  6. package/skills/lark-calendar/SKILL.md +12 -7
  7. package/skills/lark-calendar/references/lark-calendar-meeting-relation.md +99 -0
  8. package/skills/lark-calendar/references/lark-calendar-meeting.md +1 -1
  9. package/skills/lark-calendar/references/lark-calendar-recurring.md +3 -1
  10. package/skills/lark-doc/SKILL.md +1 -1
  11. package/skills/lark-doc/references/lark-doc-create-workflow.md +8 -10
  12. package/skills/lark-doc/references/lark-doc-script.md +11 -17
  13. package/skills/lark-drive/references/lark-drive-permission-guide.md +1 -1
  14. package/skills/lark-im/references/lark-im-chat-messages-list.md +2 -1
  15. package/skills/lark-im/references/lark-im-messages-mget.md +19 -2
  16. package/skills/lark-im/references/lark-im-messages-search.md +1 -1
  17. package/skills/lark-im/references/lark-im-threads-messages-list.md +1 -1
  18. package/skills/lark-mail/SKILL.md +19 -8
  19. package/skills/lark-mail/references/lark-mail-draft-create.md +1 -1
  20. package/skills/lark-mail/references/lark-mail-draft-edit.md +1 -1
  21. package/skills/lark-mail/references/lark-mail-forward.md +1 -1
  22. package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
  23. package/skills/lark-mail/references/lark-mail-reply.md +1 -1
  24. package/skills/lark-mail/references/lark-mail-rules.md +87 -4
  25. package/skills/lark-mail/references/lark-mail-send.md +1 -1
  26. package/skills/lark-mail/references/lark-mail-thread-modify.md +73 -0
  27. package/skills/lark-mail/references/lark-mail-thread-trash.md +62 -0
  28. package/skills/lark-mail/references/lark-mail-watch.md +1 -1
  29. package/skills/lark-meeting/SKILL.md +2 -2
  30. package/skills/lark-meeting/references/lark-minutes-search.md +2 -2
  31. package/skills/lark-meeting/references/lark-vc-meeting-events.md +3 -2
  32. package/skills/lark-meeting/references/lark-vc-search.md +10 -7
  33. package/skills/lark-meeting/scenes/create-and-edit-minutes.md +4 -0
  34. package/skills/lark-meeting/scenes/query-meeting-and-artifacts.md +3 -3
  35. package/skills/lark-sheets/SKILL.md +3 -1
  36. package/skills/lark-sheets/references/lark-sheets-chart.md +66 -32
  37. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +6 -3
  38. package/skills/lark-sheets/scripts/lark_chart_quality_check.py +1524 -0
  39. package/skills/lark-sheets/scripts/lark_chart_size_advisor.py +408 -0
  40. package/skills/lark-sheets/scripts/lark_chart_size_rules.py +292 -0
  41. package/skills/lark-slides/references/xml/slides_xml_schema_definition.xml +333 -20
  42. package/skills/lark-wiki/references/lark-wiki-move.md +3 -2
  43. package/skills/lark-wiki/references/lark-wiki-node-create.md +3 -2
  44. package/skills/lark-wiki/references/lark-wiki-node-delete.md +8 -4
  45. package/skills/lark-wiki/references/lark-wiki-node-get.md +7 -4
  46. package/skills/lark-sheets/scripts/lark_chart_layout_check.py +0 -472
@@ -66,7 +66,7 @@ lark-cli wiki +move \
66
66
 
67
67
  | 参数 | 必填 | 说明 |
68
68
  |------|------|------|
69
- | `--node-token` | 条件必填 | 要移动的 Wiki 节点 token。传入后命令进入 `node` 模式 |
69
+ | `--node-token` | 条件必填 | 要移动的 Wiki 节点 token 或文档 obj_token。传入后命令进入 `node` 模式 |
70
70
  | `--source-space-id` | 否 | 源知识空间 ID,仅 `node` 模式可用;不传时会根据 `--node-token` 自动解析 |
71
71
  | `--target-space-id` | 条件必填 | 目标知识空间 ID。`docs_to_wiki` 模式必填;`node` 模式下如果不传,则必须传 `--target-parent-token` |
72
72
  | `--target-parent-token` | 否 | 目标父节点 token。`docs_to_wiki` 不传时表示迁入目标知识空间根目录 |
@@ -87,8 +87,9 @@ lark-cli wiki +move \
87
87
 
88
88
  ### `node` 模式
89
89
 
90
- - **源空间解析**:如果未传 `--source-space-id`,shortcut 会先调用 `GET /open-apis/wiki/v2/spaces/get_node` 查询 `--node-token`,再读取其 `space_id`
90
+ - **源空间解析**:先调用 `GET /open-apis/wiki/v2/spaces/node_by_token` 解析源节点;未传 `--source-space-id` 时使用查询结果,传入时校验两者一致。
91
91
  - **目标父节点解析**:如果传了 `--target-parent-token`,shortcut 会先解析该父节点所属的 `space_id`
92
+ - **节点类型**:源节点和目标父节点接受 Wiki `node_token` 或文档 `obj_token`,实际移动使用查询返回的 `node_token`。
92
93
  - **一致性校验**:如果同时传了 `--target-space-id` 和 `--target-parent-token`,shortcut 会校验两者是否属于同一个知识空间;不一致时直接返回验证错误
93
94
  - **移动到空间根目录**:如果只传 `--target-space-id`,则表示移动到该知识空间根目录
94
95
 
@@ -75,7 +75,7 @@ lark-cli wiki +node-create \
75
75
  | 参数 | 必填 | 说明 |
76
76
  |------|------|------|
77
77
  | `--space-id` | 否 | 目标知识空间 ID;`user` 身份可传特殊值 `my_library` 表示个人知识库,`bot` 身份不支持该值 |
78
- | `--parent-node-token` | 否 | 父知识库节点 token;传入后会在该节点下创建新节点 |
78
+ | `--parent-node-token` | 否 | 父知识库节点 token 或文档 obj_token;在解析出的 Wiki 节点下创建新节点 |
79
79
  | `--title` | 否 | 节点标题 |
80
80
  | `--node-type` | 否 | 节点类型,默认 `origin`;可选值:`origin`、`shortcut` |
81
81
  | `--obj-type` | 否 | 节点对应对象类型,默认 `docx`;可选值:`sheet`、`mindnote`、`bitable`、`file`、`docx`、`slides`。`file` 仅支持 `shortcut` 节点 |
@@ -85,7 +85,8 @@ lark-cli wiki +node-create \
85
85
 
86
86
  - **优先级**:`--space-id` > `--parent-node-token` > `my_library`
87
87
  - **显式 space**:传了 `--space-id` 时,shortcut 会直接使用该空间;如果该值是 `my_library`,则仅 `user` 身份可用,并会先调用 `GET /open-apis/wiki/v2/spaces/my_library` 解析成真实 `space_id`
88
- - **父节点推断**:未传 `--space-id` 但传了 `--parent-node-token` 时,会先调用 `GET /open-apis/wiki/v2/spaces/get_node` 获取父节点,再读取其 `space_id`
88
+ - **父节点推断**:未传 `--space-id` 但传了 `--parent-node-token` 时,会先调用 `GET /open-apis/wiki/v2/spaces/node_by_token` 获取父节点,再读取其 `space_id`
89
+ - **父节点类型**:`--parent-node-token` 接受 Wiki `node_token` 或已挂载到 Wiki 的文档 `obj_token`,创建时使用查询返回的 `node_token`;显式传空间时也会查询并校验父节点空间。
89
90
  - **个人知识库回退**:`user` 身份下,如果 `--space-id` 和 `--parent-node-token` 都没传,会自动解析 `my_library`
90
91
  - **bot 身份限制**:`bot` 身份既没有“个人知识库”回退语义,也不支持显式传 `--space-id my_library`;请改用真实 `space_id` 或 `--parent-node-token`
91
92
 
@@ -27,12 +27,16 @@ lark-cli wiki +node-delete --node-token <token> --obj-type wiki --dry-run
27
27
  | Flag | Type | Required | Default | Description |
28
28
  |------|------|----------|---------|-------------|
29
29
  | `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one; URL paths also imply `--obj-type` |
30
- | `--obj-type` | enum | Conditional | — | Required for a raw token (URL inputs auto-infer). `wiki` = the token is a `node_token`; otherwise the cloud-doc type |
31
- | `--space-id` | string | No | — | Auto-resolved via `get_node` when omitted (extra lookup; pass it to skip) |
30
+ | `--obj-type` | enum | Conditional | — | Required for a raw token (URL inputs auto-infer). `wiki` uses the resolved `node_token`; other types use the resolved `obj_token` and must match its document type |
31
+ | `--space-id` | string | No | — | Assert the resolved node belongs to this space; inferred when omitted |
32
32
  | `--include-children` | bool | No | `true` | Cascade-delete the subtree (default). `--include-children=false` lifts direct children up to the parent |
33
33
  | `--yes` | bool | Yes (real delete) | — | Confirm the high-risk operation. Without it the CLI returns `confirmation_required` |
34
34
  | `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
35
35
 
36
+ The lookup sends only `token`, including when `--space-id` is provided. Deletion uses the resolved token matching `--obj-type`; a document-type mismatch is rejected before deletion.
37
+
38
+ For a Wiki shortcut, use `--obj-type wiki` to delete the shortcut itself. Other types are rejected because the shortcut's `obj_token` identifies its origin document.
39
+
36
40
  ## Output
37
41
 
38
42
  ```json
@@ -57,6 +61,6 @@ Async/timeout adds `task_id`, `timed_out`, and `next_command`.
57
61
  - `131011` → the node has delete-approval enabled; apply via the Wiki UI (CLI cannot bypass approval).
58
62
  - `131003` → subtree too large to cascade-delete; use `--include-children=false` or delete sub-trees first.
59
63
 
60
- ## Required Scope
64
+ ## Required Scopes
61
65
 
62
- `wiki:node:create` (the delete endpoint declares this scope). Auto-resolving `space_id` additionally needs `wiki:node:retrieve`; pass `--space-id` to avoid that lookup.
66
+ Both `wiki:node:create` (deletion) and `wiki:node:retrieve` (target lookup) are required, including when `--space-id` is provided. The server also checks access to the target node.
@@ -7,7 +7,6 @@ Get a wiki node's details by `node_token`, `obj_token`, or a Lark URL. Use this
7
7
  ```bash
8
8
  lark-cli wiki +node-get \
9
9
  --node-token <node_token | obj_token | Lark URL> \
10
- [--obj-type <doc|docx|sheet|bitable|mindnote|slides|file>] \
11
10
  [--space-id <space_id>] \
12
11
  [--format json|pretty|table|csv|ndjson] \
13
12
  [--as user|bot]
@@ -19,7 +18,6 @@ lark-cli wiki +node-get \
19
18
  |------|------|----------|---------|-------------|
20
19
  | `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`). Matches the `--node-token` naming used by sibling `+node-delete` / `+node-copy` / `+move`. |
21
20
  | `--token` | string | — (deprecated) | — | Deprecated original name; still accepted for backward compatibility but emits a `Flag --token has been deprecated, use --node-token instead` warning on stderr. New scripts should use `--node-token`. |
22
- | `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from typed Lark URLs. If omitted for a raw token, the shortcut treats it as a wiki `node_token`. |
23
21
  | `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
24
22
  | `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
25
23
  | `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
@@ -48,9 +46,10 @@ lark-cli wiki +node-get \
48
46
 
49
47
  ## Notes
50
48
 
51
- - The underlying API is `GET /open-apis/wiki/v2/spaces/get_node`. For a `node_token` no `obj_type` is sent; for an `obj_token` the `obj_type` (explicit or URL-inferred) is required.
49
+ - The underlying API is `GET /open-apis/wiki/v2/spaces/node_by_token`. Only `token` is sent; the server detects whether it is a Wiki or document token and validates its length. The CLI still requires a nonempty token and validates URL syntax.
50
+ - `--obj-type` is deprecated and hidden. Legacy scripts may still pass it; its value is ignored without a warning or any extra stdout/stderr output. URL paths are used only to extract tokens, not to assert the returned object type. `--space-id` remains a response cross-check.
52
51
  - `creator` falls back to `creator` when `node_creator` is absent. `updated_at` is `obj_edit_time` formatted as RFC3339.
53
- - No `url` is returned: `get_node` does not provide one and a synthesized `www.feishu.cn/wiki/<node_token>` link is non-canonical/misleading for a read command. Use `node_token` / `obj_token` as the identifiers.
52
+ - The shortcut preserves its existing output fields and does not emit or synthesize a `url`. Use `node_token` / `obj_token` as the identifiers.
54
53
 
55
54
  ## Terminal business errors
56
55
 
@@ -58,10 +57,14 @@ These HTTP 200 responses carry a non-zero business code and are not retryable wi
58
57
 
59
58
  | Code | Meaning | Required action |
60
59
  |------|---------|-----------------|
60
+ | `131005` | The Wiki node does not exist | Check the token or obtain a current Wiki link |
61
61
  | `131006` | The current user or app/bot identity lacks access to the Wiki node or space | This is resource access, not app scope authorization. Do not retry the same request, reauthorize, or switch identity as trial and error; ask the node owner or wiki administrator to grant read access, or use an accessible resource |
62
62
  | `131012` | The Wiki node has been deleted | Do not retry the same node token; rediscover the node or ask for a current Wiki link |
63
63
  | `131013` | The resource token is invalid | Do not switch identity or reauthorize; correct the URL/token |
64
64
  | `131014` | The document is not mounted in Wiki | Stop Wiki resolution; use the corresponding docs/sheets/base/drive command, or provide a Wiki URL/node_token |
65
+ | `131016` | The token is too short | Provide the complete token or document URL; do not retry the same input |
66
+
67
+ HTTP 200 alone does not mean success: non-zero business codes still produce a CLI failure. `131001` (invalid request) and gateway errors may still return HTTP 4xx/5xx.
65
68
 
66
69
  ## Rate limiting
67
70
 
@@ -1,472 +0,0 @@
1
- #!/usr/bin/env python3
2
- # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
- # SPDX-License-Identifier: MIT
4
- """Check whether Lark Sheet charts have obvious placement problems.
5
-
6
- The single required argument is a spreadsheet URL or spreadsheet token. By
7
- default every worksheet is checked; pass --worksheet-id to restrict the check
8
- to one worksheet reference_id.
9
-
10
- Exit codes:
11
- 0: check completed and no layout issue was found
12
- 1: the check could not be completed (CLI/read/response error)
13
- 2: check completed and at least one layout issue was found
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import argparse
19
- import json
20
- from typing import Any
21
-
22
- from lark_sheet_read_cli import (
23
- LarkCliError,
24
- emit_error,
25
- envelope_data,
26
- resolve_target_sheets,
27
- run_sheets,
28
- sheet_identifier,
29
- sheet_title,
30
- )
31
-
32
- ACTION = "chart_layout_check"
33
- DEFAULT_COLUMN_WIDTH = 105.0
34
- DEFAULT_ROW_HEIGHT = 27.0
35
-
36
-
37
- def column_to_index(column: str) -> int:
38
- value = 0
39
- text = str(column).strip().upper()
40
- if not text or not text.isalpha():
41
- raise ValueError(f"Invalid column: {column!r}")
42
- for char in text:
43
- value = value * 26 + ord(char) - ord("A") + 1
44
- return value - 1
45
-
46
-
47
- def index_to_column(index: int) -> str:
48
- if index < 0:
49
- raise ValueError(f"Invalid column index: {index}")
50
- chars: list[str] = []
51
- value = index + 1
52
- while value:
53
- value, remainder = divmod(value - 1, 26)
54
- chars.append(chr(ord("A") + remainder))
55
- return "".join(reversed(chars))
56
-
57
-
58
- def _span_bounds(span: str, *, columns: bool) -> tuple[int, int]:
59
- start, separator, end = str(span).partition(":")
60
- end = end if separator else start
61
- if columns:
62
- return column_to_index(start), column_to_index(end)
63
- return int(start) - 1, int(end) - 1
64
-
65
-
66
- def _size_edges(
67
- groups: Any,
68
- *,
69
- count: int,
70
- span_key: str,
71
- size_key: str,
72
- columns: bool,
73
- default_size: float,
74
- ) -> tuple[list[float], bool]:
75
- sizes: list[float | None] = [None] * count
76
- if isinstance(groups, list):
77
- for group in groups:
78
- if not isinstance(group, dict) or group.get(span_key) is None:
79
- continue
80
- start, end = _span_bounds(str(group[span_key]), columns=columns)
81
- size = float(group.get(size_key, default_size))
82
- for index in range(max(0, start), min(count - 1, end) + 1):
83
- sizes[index] = max(0.0, size)
84
-
85
- used_default = any(size is None for size in sizes)
86
- resolved = [default_size if size is None else size for size in sizes]
87
- edges = [0.0]
88
- for size in resolved:
89
- edges.append(edges[-1] + size)
90
- return edges, used_default
91
-
92
-
93
- def build_layout(
94
- structure: dict[str, Any], row_count: int, column_count: int
95
- ) -> tuple[list[float], list[float], list[str]]:
96
- row_groups = structure.get("row_heights")
97
- column_groups = structure.get("col_widths", structure.get("column_widths"))
98
- row_edges, row_defaulted = _size_edges(
99
- row_groups,
100
- count=row_count,
101
- span_key="rows",
102
- size_key="height",
103
- columns=False,
104
- default_size=DEFAULT_ROW_HEIGHT,
105
- )
106
- column_edges, column_defaulted = _size_edges(
107
- column_groups,
108
- count=column_count,
109
- span_key="cols",
110
- size_key="width",
111
- columns=True,
112
- default_size=DEFAULT_COLUMN_WIDTH,
113
- )
114
- warnings: list[str] = []
115
- if row_defaulted:
116
- warnings.append("部分行缺少高度信息,按 27 px 估算")
117
- if column_defaulted:
118
- warnings.append("部分列缺少宽度信息,按 105 px 估算")
119
- return row_edges, column_edges, warnings
120
-
121
-
122
- def _first_dict(value: Any) -> dict[str, Any] | None:
123
- if isinstance(value, dict):
124
- return value
125
- if isinstance(value, list):
126
- return next((item for item in value if isinstance(item, dict)), None)
127
- return None
128
-
129
-
130
- def extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]:
131
- sheet = _first_dict(data.get("sheets")) or _first_dict(data.get("sheet"))
132
- return sheet or data
133
-
134
-
135
- def extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]:
136
- sheets = data.get("sheets")
137
- if isinstance(sheets, list):
138
- for sheet in sheets:
139
- if not isinstance(sheet, dict):
140
- continue
141
- if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title:
142
- charts = sheet.get("charts")
143
- return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
144
- charts = data.get("charts")
145
- return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
146
-
147
-
148
- def chart_rectangle(
149
- chart: dict[str, Any], row_edges: list[float], column_edges: list[float]
150
- ) -> dict[str, Any]:
151
- details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
152
- position = details.get("position") if isinstance(details.get("position"), dict) else {}
153
- offset = details.get("offset") if isinstance(details.get("offset"), dict) else {}
154
- size = details.get("size") if isinstance(details.get("size"), dict) else {}
155
-
156
- row = int(position["row"])
157
- column = column_to_index(str(position["col"]))
158
- if row < 0 or column < 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1:
159
- raise ValueError(f"anchor outside sheet: {position!r}")
160
-
161
- width = float(size["width"])
162
- height = float(size["height"])
163
- if width <= 0 or height <= 0:
164
- raise ValueError(f"invalid chart size: {size!r}")
165
-
166
- left = column_edges[column] + float(offset.get("col_offset", 0) or 0)
167
- top = row_edges[row] + float(offset.get("row_offset", 0) or 0)
168
- return {
169
- "chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
170
- "anchor_cell": f"{index_to_column(column)}{row + 1}",
171
- "left": left,
172
- "top": top,
173
- "right": left + width,
174
- "bottom": top + height,
175
- "width": width,
176
- "height": height,
177
- }
178
-
179
-
180
- def intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None:
181
- left = max(float(first["left"]), float(second["left"]))
182
- top = max(float(first["top"]), float(second["top"]))
183
- right = min(float(first["right"]), float(second["right"]))
184
- bottom = min(float(first["bottom"]), float(second["bottom"]))
185
- if right <= left or bottom <= top:
186
- return None
187
- return {
188
- "width": round(right - left, 2),
189
- "height": round(bottom - top, 2),
190
- "area": round((right - left) * (bottom - top), 2),
191
- }
192
-
193
-
194
- def chart_context(rectangle: dict[str, Any]) -> dict[str, Any]:
195
- return {
196
- "chart_id": rectangle["chart_id"],
197
- "anchor_cell": rectangle["anchor_cell"],
198
- "rectangle_px": {
199
- "left": round(rectangle["left"], 2),
200
- "top": round(rectangle["top"], 2),
201
- "right": round(rectangle["right"], 2),
202
- "bottom": round(rectangle["bottom"], 2),
203
- "width": round(rectangle["width"], 2),
204
- "height": round(rectangle["height"], 2),
205
- },
206
- }
207
-
208
-
209
- def _covered_indexes(edges: list[float], start: float, end: float) -> list[int]:
210
- return [
211
- index
212
- for index in range(len(edges) - 1)
213
- if edges[index + 1] > start and edges[index] < end
214
- ]
215
-
216
-
217
- def rectangle_cell_range(
218
- rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float]
219
- ) -> str | None:
220
- rows = _covered_indexes(row_edges, max(0.0, rectangle["top"]), rectangle["bottom"])
221
- columns = _covered_indexes(column_edges, max(0.0, rectangle["left"]), rectangle["right"])
222
- if not rows or not columns:
223
- return None
224
- return f"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}"
225
-
226
-
227
- def _has_content(cell: Any) -> bool:
228
- if not isinstance(cell, dict):
229
- return False
230
- for key in ("value", "formula", "note"):
231
- value = cell.get(key)
232
- if value not in (None, ""):
233
- return True
234
- return bool(cell.get("rich_text") or cell.get("multiple_values"))
235
-
236
-
237
- def non_empty_cells(data: dict[str, Any], sample_limit: int) -> tuple[int, list[str], bool]:
238
- count = 0
239
- samples: list[str] = []
240
- truncated = bool(data.get("has_more"))
241
- ranges = data.get("ranges")
242
- if not isinstance(ranges, list):
243
- return 0, [], truncated
244
- for result_range in ranges:
245
- if not isinstance(result_range, dict):
246
- continue
247
- truncated = truncated or bool(result_range.get("truncated"))
248
- cells = result_range.get("cells")
249
- rows = result_range.get("row_indices")
250
- columns = result_range.get("col_indices")
251
- if not isinstance(cells, list):
252
- continue
253
- for row_offset, row in enumerate(cells):
254
- if not isinstance(row, list):
255
- continue
256
- row_number = rows[row_offset] if isinstance(rows, list) and row_offset < len(rows) else row_offset + 1
257
- for column_offset, cell in enumerate(row):
258
- if not _has_content(cell):
259
- continue
260
- count += 1
261
- if len(samples) < sample_limit:
262
- column = columns[column_offset] if isinstance(columns, list) and column_offset < len(columns) else index_to_column(column_offset)
263
- samples.append(f"{column}{row_number}")
264
- return count, samples, truncated
265
-
266
-
267
- def _locator(target: str) -> dict[str, str]:
268
- return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target}
269
-
270
-
271
- def _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]:
272
- row_count = int(sheet.get("row_count") or sheet.get("rowCount") or 0)
273
- column_count = int(sheet.get("column_count") or sheet.get("columnCount") or 0)
274
- if row_count <= 0 or column_count <= 0:
275
- raise LarkCliError(f"Missing row_count/column_count for sheet {sheet_title(sheet)!r}")
276
- return row_count, column_count
277
-
278
-
279
- def check_sheet(
280
- locator: dict[str, str], sheet: dict[str, Any], *, timeout: int, sample_limit: int
281
- ) -> dict[str, Any]:
282
- sheet_id = sheet_identifier(sheet)
283
- title = sheet_title(sheet)
284
- row_count, column_count = _sheet_counts(sheet)
285
- if not sheet_id:
286
- raise LarkCliError(f"Missing sheet_id for sheet {title!r}")
287
-
288
- structure_data = envelope_data(
289
- run_sheets(
290
- "+sheet-info",
291
- **locator,
292
- sheet_id=sheet_id,
293
- flags={"include": "row_heights,col_widths"},
294
- timeout=timeout,
295
- )
296
- )
297
- row_edges, column_edges, warnings = build_layout(
298
- extract_sheet_structure(structure_data), row_count, column_count
299
- )
300
- chart_data = envelope_data(
301
- run_sheets("+chart-list", **locator, sheet_id=sheet_id, timeout=timeout)
302
- )
303
- charts = extract_charts(chart_data, sheet_id, title)
304
-
305
- rectangles: list[dict[str, Any]] = []
306
- unverifiable: list[dict[str, str]] = []
307
- expected_chart_count = sheet.get("chart_count")
308
- if expected_chart_count is not None and int(expected_chart_count) != len(charts):
309
- unverifiable.append(
310
- {
311
- "chart_id": "",
312
- "reason": (
313
- f"chart-list returned {len(charts)} charts, "
314
- f"but workbook-info reported {int(expected_chart_count)}"
315
- ),
316
- }
317
- )
318
- for chart in charts:
319
- chart_id = str(chart.get("chart_id") or chart.get("id") or "")
320
- if not chart_id:
321
- unverifiable.append({"chart_id": "", "reason": "chart is missing chart_id"})
322
- continue
323
- try:
324
- rectangles.append(chart_rectangle(chart, row_edges, column_edges))
325
- except (KeyError, TypeError, ValueError) as exc:
326
- unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
327
-
328
- overlaps: list[dict[str, Any]] = []
329
- for index, first in enumerate(rectangles):
330
- for second in rectangles[index + 1 :]:
331
- overlap = intersection(first, second)
332
- if overlap:
333
- overlaps.append(
334
- {
335
- "chart_ids": [first["chart_id"], second["chart_id"]],
336
- "charts": [chart_context(first), chart_context(second)],
337
- "intersection": overlap,
338
- }
339
- )
340
-
341
- sheet_width = column_edges[-1]
342
- sheet_height = row_edges[-1]
343
- out_of_bounds: list[dict[str, Any]] = []
344
- content_overlaps: list[dict[str, Any]] = []
345
- for rectangle in rectangles:
346
- overflow = {
347
- "left": round(max(0.0, -rectangle["left"]), 2),
348
- "top": round(max(0.0, -rectangle["top"]), 2),
349
- "right": round(max(0.0, rectangle["right"] - sheet_width), 2),
350
- "bottom": round(max(0.0, rectangle["bottom"] - sheet_height), 2),
351
- }
352
- if any(overflow.values()):
353
- out_of_bounds.append({**chart_context(rectangle), "overflow_px": overflow})
354
-
355
- covered_range = rectangle_cell_range(rectangle, row_edges, column_edges)
356
- if not covered_range:
357
- continue
358
- cells_data = envelope_data(
359
- run_sheets(
360
- "+cells-get",
361
- **locator,
362
- sheet_id=sheet_id,
363
- flags={"range": covered_range, "include": "value,formula,comment"},
364
- timeout=timeout,
365
- )
366
- )
367
- count, samples, truncated = non_empty_cells(cells_data, sample_limit)
368
- if truncated:
369
- unverifiable.append(
370
- {"chart_id": rectangle["chart_id"], "reason": f"cells-get truncated for {covered_range}"}
371
- )
372
- if count:
373
- content_overlaps.append(
374
- {
375
- **chart_context(rectangle),
376
- "covered_range": covered_range,
377
- "non_empty_cell_count": count,
378
- "sample_cells": samples,
379
- }
380
- )
381
-
382
- issue_count = len(overlaps) + len(out_of_bounds) + len(content_overlaps)
383
- return {
384
- "sheet_id": sheet_id,
385
- "sheet_name": title,
386
- "chart_count": len(charts),
387
- "sheet_size_px": {"width": round(sheet_width, 2), "height": round(sheet_height, 2)},
388
- "chart_overlaps": overlaps,
389
- "cell_content_overlaps": content_overlaps,
390
- "out_of_visible_range": out_of_bounds,
391
- "unverifiable_charts": unverifiable,
392
- "issue_count": issue_count,
393
- "unverifiable_count": len(unverifiable),
394
- "warnings": warnings,
395
- }
396
-
397
-
398
- def parse_args() -> argparse.Namespace:
399
- parser = argparse.ArgumentParser(
400
- description="Check chart overlap, covered cell content, and worksheet boundary overflow."
401
- )
402
- parser.add_argument("sheet_id", help="Spreadsheet URL or spreadsheet token")
403
- parser.add_argument("--worksheet-id", help="Only check this worksheet reference_id")
404
- parser.add_argument("--timeout", type=int, default=60)
405
- parser.add_argument("--sample-limit", type=int, default=10)
406
- return parser.parse_args()
407
-
408
-
409
- def success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]:
410
- issue_count = sum(result["issue_count"] for result in results)
411
- unverifiable_count = sum(result["unverifiable_count"] for result in results)
412
- warnings = [
413
- f"{result['sheet_name'] or result['sheet_id']}: {warning}"
414
- for result in results
415
- for warning in result["warnings"]
416
- ]
417
- return {
418
- "ok": True,
419
- "engine": "lark",
420
- "action": ACTION,
421
- "data": {
422
- "passed": issue_count == 0 and unverifiable_count == 0,
423
- "scope_note": "out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport",
424
- "summary": {
425
- "worksheet_count": len(results),
426
- "chart_count": sum(result["chart_count"] for result in results),
427
- "issue_count": issue_count,
428
- "unverifiable_count": unverifiable_count,
429
- },
430
- "sheets": results,
431
- },
432
- "warnings": warnings,
433
- }
434
-
435
-
436
- def report_exit_code(report: dict[str, Any]) -> int:
437
- if report["data"]["passed"]:
438
- return 0
439
- if report["data"]["summary"]["issue_count"] > 0:
440
- return 2
441
- return 1
442
-
443
-
444
- def main() -> None:
445
- args = parse_args()
446
- locator = _locator(args.sheet_id)
447
- try:
448
- workbook_data = envelope_data(
449
- run_sheets("+workbook-info", **locator, timeout=args.timeout)
450
- )
451
- sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id)
452
- if not args.worksheet_id:
453
- sheets = [sheet for sheet in sheets if not bool(sheet.get("is_hidden"))]
454
- if not sheets:
455
- raise LarkCliError("No visible worksheet matched")
456
- results = [
457
- check_sheet(locator, sheet, timeout=args.timeout, sample_limit=args.sample_limit)
458
- for sheet in sheets
459
- ]
460
- except (LarkCliError, KeyError, TypeError, ValueError) as exc:
461
- emit_error(ACTION, str(exc))
462
- raise SystemExit(1) from exc
463
-
464
- report = success_envelope(results)
465
- print(json.dumps(report, ensure_ascii=False, indent=2))
466
- exit_code = report_exit_code(report)
467
- if exit_code:
468
- raise SystemExit(exit_code)
469
-
470
-
471
- if __name__ == "__main__":
472
- main()