@amaster.ai/pi-lark 0.1.2-beta.70 → 0.1.2-beta.72
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-base/SKILL.md +4 -3
- package/skills/lark-base/references/lark-base-app.md +2 -2
- package/skills/lark-base/references/lark-base-dashboard-block-config.md +1 -1
- package/skills/lark-calendar/SKILL.md +11 -6
- package/skills/lark-calendar/references/lark-calendar-meeting-relation.md +99 -0
- package/skills/lark-calendar/references/lark-calendar-meeting.md +1 -1
- package/skills/lark-calendar/references/lark-calendar-recurring.md +3 -1
- package/skills/lark-doc/SKILL.md +1 -1
- package/skills/lark-doc/references/lark-doc-create-workflow.md +8 -10
- package/skills/lark-doc/references/lark-doc-script.md +11 -17
- package/skills/lark-drive/references/lark-drive-inspect.md +1 -1
- package/skills/lark-im/SKILL.md +7 -1
- package/skills/lark-im/references/lark-im-chat-messages-list.md +4 -0
- package/skills/lark-im/references/lark-im-messages-mget.md +1 -1
- package/skills/lark-im/references/lark-im-messages-resources-download.md +2 -0
- package/skills/lark-im/references/lark-im-threads-messages-list.md +4 -0
- package/skills/lark-mail/SKILL.md +9 -4
- package/skills/lark-mail/references/lark-mail-thread-modify.md +73 -0
- package/skills/lark-mail/references/lark-mail-thread-trash.md +62 -0
- package/skills/lark-meeting/SKILL.md +2 -2
- package/skills/lark-meeting/references/lark-minutes-search.md +2 -2
- package/skills/lark-meeting/references/lark-vc-search.md +3 -3
- package/skills/lark-meeting/scenes/create-and-edit-minutes.md +4 -0
- package/skills/lark-shared/references/lark-wiki-token-routing.md +7 -7
- package/skills/lark-sheets/SKILL.md +3 -1
- package/skills/lark-sheets/references/lark-sheets-chart.md +66 -32
- package/skills/lark-sheets/references/lark-sheets-visual-standards.md +6 -3
- package/skills/lark-sheets/scripts/lark_chart_quality_check.py +1524 -0
- package/skills/lark-sheets/scripts/lark_chart_size_advisor.py +408 -0
- package/skills/lark-sheets/scripts/lark_chart_size_rules.py +292 -0
- package/skills/lark-slides/references/cli/lark-slides-add-slide.md +1 -1
- package/skills/lark-slides/references/cli/lark-slides-media-upload.md +1 -1
- package/skills/lark-slides/references/cli/lark-slides-replace-slide.md +1 -1
- package/skills/lark-slides/references/xml/slides_xml_schema_definition.xml +333 -20
- package/skills/lark-wiki/SKILL.md +1 -2
- package/skills/lark-wiki/references/lark-wiki-move.md +3 -2
- package/skills/lark-wiki/references/lark-wiki-node-create.md +3 -2
- package/skills/lark-wiki/references/lark-wiki-node-delete.md +8 -4
- package/skills/lark-wiki/references/lark-wiki-node-get.md +7 -4
- package/skills/lark-sheets/scripts/lark_chart_layout_check.py +0 -472
|
@@ -49,7 +49,7 @@ metadata:
|
|
|
49
49
|
|
|
50
50
|
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
|
51
51
|
|
|
52
|
-
获取或解析 Wiki
|
|
52
|
+
获取或解析 Wiki 节点统一使用 `wiki +node-get`,包括只为获取 `space_id`、`node_token`、`obj_token` 或 `obj_type` 的中间步骤。
|
|
53
53
|
|
|
54
54
|
| Shortcut | 说明 |
|
|
55
55
|
|----------|------|
|
|
@@ -95,7 +95,6 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
|
|
95
95
|
|
|
96
96
|
- `create` — 创建知识空间
|
|
97
97
|
- `get` — 获取知识空间信息
|
|
98
|
-
- `get_node` — 获取知识空间节点信息
|
|
99
98
|
- `list` — 获取知识空间列表
|
|
100
99
|
|
|
101
100
|
### members
|
|
@@ -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
|
-
-
|
|
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/
|
|
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`
|
|
31
|
-
| `--space-id` | string | No | — |
|
|
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
|
|
64
|
+
## Required Scopes
|
|
61
65
|
|
|
62
|
-
`wiki:node:create` (
|
|
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/
|
|
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
|
-
-
|
|
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()
|