sheets CRITICAL — 开始前 MUST 先用 Read 工具读取 ,其中包含认证、权限处理。 场景 → 命令速查 按当前动作选行;下一步必须 Read 该行 reference,读取完成前不得执行命令。只读命中的文档;含公式 / 样式等横切动作时再读对应规范,禁止用目录枚举代替 Read。 | 你要做的事 | ✅ 正确写法 | 动手前读(先 Read 再动手) | | --- | --- | --- | | 读数据 | (纯值/CSV)、 (公式/样式/批注) | 读 | | 写入数据 | (无类型歧义纯文本)、 (typed;量值/真日期;标签/编号/前导零/文本数字用 object,禁裸 csv-put)、 (公式/富写入)、 (样式)、 (单元格图片) | 读 | | 格式继承(新列/新行) | 物理插行 / 插列用 ;往已有空白区域扩写用 先铺样式再写值 | 读 ;插行插列再读 | | 工作簿操作 | 、 、 、 、 、 | 读 | | 行列操作 | 排序用 原子移动整行;合并 / 取消合并用 / ;清空内容才用 ;尺寸用 / | 读 ;涉结构布局再读 | | 美化收尾 | | 读 | | 子表结构 | 、 ;删整行 / 列用 ,不能用 clear 代替 | 读 | | 画图表 / 可视化 / 柱状图 / 折线图 / 饼图 / 趋势 / 占比 | 单图用 ,…

name,score\\nalice,95\\nbob,87'\n\n# 从文件\nlark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id \"$SID\" \\\n --start-cell \"A1\" --csv @data.csv\n```\n\n> `+csv-put` 比 `+cells-set` 短得多——批量灌值或公式时优先用它。需要样式/批注/图片才换 `+cells-set`。\n>\n> ✅ `=` 开头的单元格会被当作公式计算(不是字面量文本):\n>\n> ```bash\n> lark-cli sheets +csv-put --url \"...\" --sheet-name \"Sheet1\" \\\n> --start-cell \"A1\" \\\n> --csv

sheets CRITICAL — 开始前 MUST 先用 Read 工具读取 ,其中包含认证、权限处理。 场景 → 命令速查 按当前动作选行;下一步必须 Read 该行 reference,读取完成前不得执行命令。只读命中的文档;含公式 / 样式等横切动作时再读对应规范,禁止用目录枚举代替 Read。 | 你要做的事 | ✅ 正确写法 | 动手前读(先 Read 再动手) | | --- | --- | --- | | 读数据 | (纯值/CSV)、 (公式/样式/批注) | 读 | | 写入数据 | (无类型歧义纯文本)、 (typed;量值/真日期;标签/编号/前导零/文本数字用 object,禁裸 csv-put)、 (公式/富写入)、 (样式)、 (单元格图片) | 读 | | 格式继承(新列/新行) | 物理插行 / 插列用 ;往已有空白区域扩写用 先铺样式再写值 | 读 ;插行插列再读 | | 工作簿操作 | 、 、 、 、 、 | 读 | | 行列操作 | 排序用 原子移动整行;合并 / 取消合并用 / ;清空内容才用 ;尺寸用 / | 读 ;涉结构布局再读 | | 美化收尾 | | 读 | | 子表结构 | 、 ;删整行 / 列用 ,不能用 clear 代替 | 读 | | 画图表 / 可视化 / 柱状图 / 折线图 / 饼图 / 趋势 / 占比 | 单图用 ,…

name,score\\nalice,=SUM(B2:B10)'\n> # ↑ B2 写入公式 =SUM(B2:B10),读回 formula 保留、value 为计算结果。\n> # 反过来:无法用 +csv-put 写「= 开头的字面量文本」(会被当公式);样式/批注/图片仍用 +cells-set。\n> ```\n>\n> ⚠️ **公式内部含逗号 / 引号建议 RFC 4180 转义**:CSV 用逗号分隔字段,公式里的逗号(如 `COUNTIF(D5:D22,\"及格\")` 的参数分隔逗号)会被解析器当成字段分隔符,把一格拆成多格、整块二维结构压扁错位。规则:**含逗号的字段整格用双引号包裹,字段内部的引号再翻倍**:\n>\n> ```bash\n> # 从 G4 写一个 2 列 3 行的统计块;=COUNTIF 含逗号 + 内部引号,建议转义\n> lark-cli sheets +csv-put --url \"...\" --sheet-name \"Sheet1\" \\\n> --start-cell \"G4\" \\\n> --csv

sheets CRITICAL — 开始前 MUST 先用 Read 工具读取 ,其中包含认证、权限处理。 场景 → 命令速查 按当前动作选行;下一步必须 Read 该行 reference,读取完成前不得执行命令。只读命中的文档;含公式 / 样式等横切动作时再读对应规范,禁止用目录枚举代替 Read。 | 你要做的事 | ✅ 正确写法 | 动手前读(先 Read 再动手) | | --- | --- | --- | | 读数据 | (纯值/CSV)、 (公式/样式/批注) | 读 | | 写入数据 | (无类型歧义纯文本)、 (typed;量值/真日期;标签/编号/前导零/文本数字用 object,禁裸 csv-put)、 (公式/富写入)、 (样式)、 (单元格图片) | 读 | | 格式继承(新列/新行) | 物理插行 / 插列用 ;往已有空白区域扩写用 先铺样式再写值 | 读 ;插行插列再读 | | 工作簿操作 | 、 、 、 、 、 | 读 | | 行列操作 | 排序用 原子移动整行;合并 / 取消合并用 / ;清空内容才用 ;尺寸用 / | 读 ;涉结构布局再读 | | 美化收尾 | | 读 | | 子表结构 | 、 ;删整行 / 列用 ,不能用 clear 代替 | 读 | | 画图表 / 可视化 / 柱状图 / 折线图 / 饼图 / 趋势 / 占比 | 单图用 ,…

统计项,结果\\n成绩总和,=SUM(C5:C22)\\n及格人数,\"=COUNTIF(D5:D22,\"\"及格\"\")\"'\n> # ↑ \"=COUNTIF(D5:D22,\"\"及格\"\")\":外层双引号包裹整格,内部 \"及格\" 的引号翻倍成 \"\"及格\"\"。\n> # 裸写 =COUNTIF(D5:D22,\"及格\") 会被 CSV 按逗号拆成两格、写入区域从 G4:H6 错位成 G4:K4。\n> ```\n>\n> 💡 **含逗号 / 引号 / 换行的公式优先用 `+cells-set`(JSON 二维数组)写入**——`cells[r][c].formula` 直接放公式串,没有 CSV 转义负担。`+table-put` 的 typed payload 可带 `name/start_cell/mode/header/allow_overwrite/columns/data/dtypes/formats`,但没有公式字段;公式写入用 `+cells-set` 或转义后的 `+csv-put`:\n>\n> ```bash\n> # 同样的统计块,结构化写入无需任何转义\n> lark-cli sheets +cells-set --url \"...\" --sheet-name \"Sheet1\" --range \"G4:H6\" \\\n> --cells '[[{\"value\":\"统计项\"},{\"value\":\"结果\"}],[{\"value\":\"成绩总和\"},{\"formula\":\"=SUM(C5:C22)\"}],[{\"value\":\"及格人数\"},{\"formula\":\"=COUNTIF(D5:D22,\\\"及格\\\")\"}]]'\n> ```\n\n> **定位 + 写入边界(关键,避免误覆盖)**:\n> - 定位用 `--start-cell`(锚点 = 左上角单元格);也接受 `--range` 别名(与 `+csv-get` / `+cells-set` 一致,传区间会自动取左上角)。\n> - ⚠️ `--start-cell` / `--range` **只定左上角、不限制写入大小**:CSV 从锚点按自身行列数 auto-expand 铺开。给一个\"小 range\"**不会**截断数据——超出部分照写,且默认覆盖。`+cells-set` 只有裸 `--range A1` 是这个语义;`--range` 一旦写成矩形就是边界,`--cells` 超出它会被拒绝。\n> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`):**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。\n> - 要保护非空 cell:`--allow-overwrite=false`(落区内出现非空 cell 即报错)。\n\n### `+table-put`(DataFrame → 飞书,类型保真写入)\n\n把结构化数据(DataFrame、list of dict、Counter)类型保真写入**已有**表(写入语义同 `+cells-set`)。协议形状**对齐 pandas `to_json(orient=\"split\")`**:`columns:[列名]` + `data:[[行...]]`,可选 `dtypes:{列名:pandas_dtype}` 决定每列类型(number 保精度、date 落真日期),可选 `formats:{列名:number_format}` 覆盖显示格式(千分位 / 百分比 / 自定义日期)。dtypes 缺失时整张表按 string 写入(带 `@` 文本格式,邮编 / 订单号等含前导零的 id 保真)。\n\n只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同协议、一步建表 + 类型保真写入,详见 workbook reference)。读回用镜像命令 `+table-get`(见 read-data reference),输出与 `--sheets` 同构、可 round-trip。\n\n```bash\n# sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet\npython export.py | lark-cli sheets +table-put --url \"\u003c表URL>\" --sheets -\n# 某 sheet 带 \"mode\":\"append\" 追加到已有数据末尾、默认不重复表头\nlark-cli sheets +table-put --spreadsheet-token \"\u003ctoken>\" --sheets @payload.json\n# --sheets 与 --styles 都是大 JSON 时:stdin 每次调用只能给一个 flag,一个走 -、另一个走 @cwd 相对路径\nlark-cli sheets +table-put --url \"\u003c表URL>\" --sheets - --styles @styles.json \u003c sheets.json\n```\n\n每个 sheet 还可带 `\"allow_overwrite\": false`(遇非空拒写、保护原数据)、`\"header\": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。\n\n#### DataFrame → 协议(用 `df_to_sheet` helper)\n\npandas 的 `df.to_json(orient=\"split\", date_format=\"iso\")` 一步完成所有清洗(NaN→null、Timestamp→ISO 字符串、numpy 标量→原生数字),把 dtypes 拼上即可。本 skill 把这段 5 行 helper 打包成可 import 的 [`scripts/lark_sheets_df.py`](../scripts/lark_sheets_df.py)(含 `df_to_sheet` 和 `sheet_to_df`,写入 / 读回成对):\n\n```python\nimport sys; sys.path.insert(0, \"scripts\") # cwd 不在 skill 根时改成 scripts/ 的实际路径\nfrom lark_sheets_df import df_to_sheet\n\n# 单 sheet(显式 format 覆盖默认显示)\npayload = {\"sheets\": [df_to_sheet(df, \"销售\", {\"营收\": \"#,##0.00\", \"毛利率\": \"0.0%\"})]}\n\n# 多 sheet——helper 让每个 sheet 一行,不再重复 boilerplate\npayload = {\"sheets\": [df_to_sheet(df1, \"销售\"),\n df_to_sheet(df2, \"成本\"),\n df_to_sheet(df3, \"利润\")]}\n```\n\n> **CSV-shaped 全文本数据**(不需要类型保真、含前导零的 id 也要保留)省掉 dtypes 即可,inline 一行写完,不必走 helper(注意保留 `date_format=\"iso\"`,否则 datetime 列会被序列化成 epoch 毫秒数字,CLI 拒绝):\n> ```python\n> payload = {\"sheets\": [{\"name\": \"原始\",\n> **json.loads(df.to_json(orient=\"split\", date_format=\"iso\"))}]}\n> ```\n> **别把 `to_json + json.loads` 换成 `df.to_dict(orient=\"split\")`**:会留 `numpy.int64` 让 `json.dumps` 后续报 \"not serializable\"——这一步是清洗的关键。\n> **列名必须是字符串**:整数列名(如未指定表头时 pandas 默认的 0/1/2)会以 JSON 数字进入 `columns` 被 CLI 拒收;inline 写法要先 `df.columns = df.columns.map(str)`。`df_to_sheet` 已自动完成这一步。\n\n不用 pandas 也行——typed 协议就是纯 JSON。手写场景:\n\n```python\n# Counter / dict / 手拼数据:直接写 columns + data,按需加 dtypes/formats\npayload = {\"sheets\": [{\n \"name\": \"渠道\",\n \"columns\": [\"channel\", \"count\", \"rate\"],\n \"data\": [[\"app\", 1240, 0.62], [\"web\", 760, 0.38]],\n \"dtypes\": {\"count\": \"int64\", \"rate\": \"float64\"},\n \"formats\": {\"rate\": \"0.0%\"},\n}]}\n```\n\n> **dtype 速查**:`int64`/`float64`(数值)、`Int64`(含空值的整数,nullable)、`bool`/`boolean`、`datetime64[ns]`(date,默认 `yyyy-mm-dd`)、`object`(string)。pandas dtype 字符串原样塞进 dtypes 即可,CLI 端按前缀匹配(`int*`/`uint*`/`Int*`/`float*` → number 等)。未识别 dtype 兜底为 string。\n\n#### `--styles`(写入时同时套样式)\n\n`--styles` 在 typed 写入后顺带应用视觉处理,省掉一次 `+cells-set-style` 往返。协议与 `+workbook-create --styles` **完全同构**(详见 workbook reference):顶层 `{styles:[...]}`,数组每项对应一个被写入的子表、含 `name`,并按能力拆成四类可选数组——`cell_styles`(A1 单元格 range + 扁平样式字段,含 `number_format` / 颜色 / 对齐 / `border_styles`,随内容在同一次写入里一并应用)、`cell_merges`、`row_sizes`、`col_sizes`。styles 数组的长度 / 顺序 / name 必须与被写入的子表对应(与 `--sheets.sheets` 一一对应)。\n\n```bash\nlark-cli sheets +table-put --url \"\u003c表URL>\" \\\n --sheets '{\"sheets\":[{\"name\":\"明细\",\"columns\":[\"日期\",\"金额\"],\"dtypes\":{\"日期\":\"datetime64[ns]\",\"金额\":\"float64\"},\"formats\":{\"金额\":\"#,##0.00\"},\"data\":[[\"2024-01-15\",1234.5]]}]}' \\\n --styles '{\"styles\":[{\"name\":\"明细\",\n \"cell_styles\":[{\"range\":\"A1:B1\",\"font_weight\":\"bold\",\"background_color\":\"#f5f5f5\",\"horizontal_alignment\":\"center\"}],\n \"cell_merges\":[{\"range\":\"A1:B1\"}],\n \"col_sizes\":[{\"range\":\"A:B\",\"type\":\"pixel\",\"size\":120}]}]}'\n```\n\n完整字段跑 `+table-put --print-schema --flag-name styles`。\n\n### Validate / DryRun / Execute 约束\n\n- `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为各行等宽的 JSON 二维矩阵(落区按其行列数推断);`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;`+table-put` 给了 `--styles` 则按子表名 / 顺序 / 数量与 `--sheets.sheets` 对齐校验;防爆参数上限校验。\n- `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。\n- `Execute`:写后必须按写入范围回读首、中、末及用户点名项;公式同时读取 formula,并按公式完成流程验证。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":63409,"content_sha256":"6576619938339f3c30860e65428b0e99d178630f55f6191e641ccfa0c9d51fe1"},{"filename":"scripts/lark_chart_quality_check.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Check Lark Sheet chart quality, placement, and numeric source-data issues.\n\nThe single required argument is a spreadsheet URL or spreadsheet token. By\ndefault every worksheet is checked; pass --worksheet-id to restrict the check\nto one worksheet reference_id.\n\nNumeric source checks sample at most 50 data points per series and request at\nmost 2000 source cells per chart across the style and typed-value reads,\nincluding headers and gaps between series.\nSampled zero/constant values do not establish a whole-series issue.\n\nExit codes:\n 0: check completed and no issue was found\n 1: the check could not be completed (CLI/read/response error)\n 2: check completed and at least one chart-quality issue was found\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport re\nfrom typing import Any, Dict, Tuple\n\nfrom lark_sheet_read_cli import (\n LarkCliError,\n emit_error,\n envelope_data,\n resolve_target_sheets,\n run_sheets,\n sheet_identifier,\n sheet_title,\n)\nfrom lark_chart_size_rules import MAX_ASPECT_RATIO, MAX_CHART_WIDTH, minimum_chart_size\n\nACTION = \"chart_quality_check\"\nDEFAULT_COLUMN_WIDTH = 105.0\nDEFAULT_ROW_HEIGHT = 27.0\nMAX_CELL_READ_SIZE = 2_000\nMAX_SOURCE_SAMPLE_POINTS = 50\n\n\n# 类型别名是运行期求值的,__future__ annotations 豁免不了它们;\n# 内置泛型下标要 3.9,CI 的解释器是 3.7,只能走 typing 里的等价物。\nCellBounds = Tuple[int, int, int, int]\nCellCache = Dict[Tuple[str, str, str, bool], Dict[str, Any]]\nSeriesProfile = Dict[str, Any]\n\n\ndef _parse_a1_bounds(cell_range: str) -> CellBounds:\n value = str(cell_range).rsplit(\"!\", 1)[-1].replace(\"$\", \"\")\n match = re.fullmatch(r\"([A-Za-z]+)(\\d+)(?::([A-Za-z]+)(\\d+))?\", value)\n if not match:\n raise ValueError(f\"Invalid A1 range: {cell_range!r}\")\n start_column = column_to_index(match.group(1))\n start_row = int(match.group(2))\n end_column = column_to_index(match.group(3) or match.group(1))\n end_row = int(match.group(4) or match.group(2))\n if end_row \u003c start_row or end_column \u003c start_column:\n raise ValueError(f\"Invalid A1 range: {cell_range!r}\")\n return start_row, end_row, start_column, end_column\n\n\ndef _format_a1_bounds(bounds: CellBounds) -> str:\n start_row, end_row, start_column, end_column = bounds\n return (\n f\"{index_to_column(start_column)}{start_row}:\"\n f\"{index_to_column(end_column)}{end_row}\"\n )\n\n\ndef _bounds_area(bounds: CellBounds) -> int:\n start_row, end_row, start_column, end_column = bounds\n return (end_row - start_row + 1) * (end_column - start_column + 1)\n\n\ndef _merge_bounds(first: CellBounds, second: CellBounds) -> CellBounds:\n return (\n min(first[0], second[0]),\n max(first[1], second[1]),\n min(first[2], second[2]),\n max(first[3], second[3]),\n )\n\n\ndef _cluster_cell_reads(\n items: list[tuple[dict[str, Any], CellBounds]],\n) -> list[dict[str, Any]]:\n clusters: list[dict[str, Any]] = []\n for rectangle, bounds in sorted(items, key=lambda item: (item[1][0], item[1][2])):\n if clusters:\n merged = _merge_bounds(clusters[-1][\"bounds\"], bounds)\n if _bounds_area(merged) \u003c= MAX_CELL_READ_SIZE:\n clusters[-1][\"bounds\"] = merged\n clusters[-1][\"members\"].append((rectangle, bounds))\n continue\n clusters.append({\"bounds\": bounds, \"members\": [(rectangle, bounds)]})\n return clusters\n\n\ndef column_to_index(column: str) -> int:\n value = 0\n text = str(column).strip().upper()\n if not text or not text.isalpha():\n raise ValueError(f\"Invalid column: {column!r}\")\n for char in text:\n value = value * 26 + ord(char) - ord(\"A\") + 1\n return value - 1\n\n\ndef index_to_column(index: int) -> str:\n if index \u003c 0:\n raise ValueError(f\"Invalid column index: {index}\")\n chars: list[str] = []\n value = index + 1\n while value:\n value, remainder = divmod(value - 1, 26)\n chars.append(chr(ord(\"A\") + remainder))\n return \"\".join(reversed(chars))\n\n\ndef _span_bounds(span: str, *, columns: bool) -> tuple[int, int]:\n start, separator, end = str(span).partition(\":\")\n end = end if separator else start\n if columns:\n return column_to_index(start), column_to_index(end)\n return int(start) - 1, int(end) - 1\n\n\ndef _size_edges(\n groups: Any,\n *,\n count: int,\n span_key: str,\n size_key: str,\n columns: bool,\n default_size: float,\n) -> tuple[list[float], bool]:\n sizes: list[float | None] = [None] * count\n if isinstance(groups, list):\n for group in groups:\n if not isinstance(group, dict) or group.get(span_key) is None:\n continue\n start, end = _span_bounds(str(group[span_key]), columns=columns)\n size = float(group.get(size_key, default_size))\n for index in range(max(0, start), min(count - 1, end) + 1):\n sizes[index] = max(0.0, size)\n\n used_default = any(size is None for size in sizes)\n resolved = [default_size if size is None else size for size in sizes]\n edges = [0.0]\n for size in resolved:\n edges.append(edges[-1] + size)\n return edges, used_default\n\n\ndef build_layout(\n structure: dict[str, Any], row_count: int, column_count: int\n) -> tuple[list[float], list[float], list[str]]:\n row_groups = structure.get(\"row_heights\")\n column_groups = structure.get(\"col_widths\", structure.get(\"column_widths\"))\n row_edges, row_defaulted = _size_edges(\n row_groups,\n count=row_count,\n span_key=\"rows\",\n size_key=\"height\",\n columns=False,\n default_size=DEFAULT_ROW_HEIGHT,\n )\n column_edges, column_defaulted = _size_edges(\n column_groups,\n count=column_count,\n span_key=\"cols\",\n size_key=\"width\",\n columns=True,\n default_size=DEFAULT_COLUMN_WIDTH,\n )\n warnings: list[str] = []\n if row_defaulted:\n warnings.append(\"部分行缺少高度信息,按 27 px 估算\")\n if column_defaulted:\n warnings.append(\"部分列缺少宽度信息,按 105 px 估算\")\n return row_edges, column_edges, warnings\n\n\ndef _first_dict(value: Any) -> dict[str, Any] | None:\n if isinstance(value, dict):\n return value\n if isinstance(value, list):\n return next((item for item in value if isinstance(item, dict)), None)\n return None\n\n\ndef extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]:\n sheet = _first_dict(data.get(\"sheets\")) or _first_dict(data.get(\"sheet\"))\n return sheet or data\n\n\ndef extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]:\n sheets = data.get(\"sheets\")\n if isinstance(sheets, list):\n for sheet in sheets:\n if not isinstance(sheet, dict):\n continue\n if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title:\n charts = sheet.get(\"charts\")\n return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []\n charts = data.get(\"charts\")\n return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []\n\n\ndef chart_rectangle(\n chart: dict[str, Any], row_edges: list[float], column_edges: list[float]\n) -> dict[str, Any]:\n details = chart.get(\"details\") if isinstance(chart.get(\"details\"), dict) else chart\n position = details.get(\"position\") if isinstance(details.get(\"position\"), dict) else {}\n offset = details.get(\"offset\") if isinstance(details.get(\"offset\"), dict) else {}\n size = details.get(\"size\") if isinstance(details.get(\"size\"), dict) else {}\n\n row = int(position[\"row\"])\n column = column_to_index(str(position[\"col\"]))\n if row \u003c 0 or column \u003c 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1:\n raise ValueError(f\"anchor outside sheet: {position!r}\")\n\n width = float(size[\"width\"])\n height = float(size[\"height\"])\n if width \u003c= 0 or height \u003c= 0:\n raise ValueError(f\"invalid chart size: {size!r}\")\n\n left = column_edges[column] + float(offset.get(\"col_offset\", 0) or 0)\n top = row_edges[row] + float(offset.get(\"row_offset\", 0) or 0)\n return {\n \"chart_id\": str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\"),\n \"anchor_cell\": f\"{index_to_column(column)}{row + 1}\",\n \"left\": left,\n \"top\": top,\n \"right\": left + width,\n \"bottom\": top + height,\n \"width\": width,\n \"height\": height,\n }\n\n\ndef intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None:\n left = max(float(first[\"left\"]), float(second[\"left\"]))\n top = max(float(first[\"top\"]), float(second[\"top\"]))\n right = min(float(first[\"right\"]), float(second[\"right\"]))\n bottom = min(float(first[\"bottom\"]), float(second[\"bottom\"]))\n if right \u003c= left or bottom \u003c= top:\n return None\n return {\n \"width\": round(right - left, 2),\n \"height\": round(bottom - top, 2),\n \"area\": round((right - left) * (bottom - top), 2),\n }\n\n\ndef chart_context(rectangle: dict[str, Any]) -> dict[str, Any]:\n return {\n \"chart_id\": rectangle[\"chart_id\"],\n \"anchor_cell\": rectangle[\"anchor_cell\"],\n \"rectangle_px\": {\n \"left\": round(rectangle[\"left\"], 2),\n \"top\": round(rectangle[\"top\"], 2),\n \"right\": round(rectangle[\"right\"], 2),\n \"bottom\": round(rectangle[\"bottom\"], 2),\n \"width\": round(rectangle[\"width\"], 2),\n \"height\": round(rectangle[\"height\"], 2),\n },\n }\n\n\ndef _covered_indexes(edges: list[float], start: float, end: float) -> list[int]:\n return [\n index\n for index in range(len(edges) - 1)\n if edges[index + 1] > start and edges[index] \u003c end\n ]\n\n\ndef rectangle_cell_range(\n rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float]\n) -> str | None:\n rows = _covered_indexes(row_edges, max(0.0, rectangle[\"top\"]), rectangle[\"bottom\"])\n columns = _covered_indexes(column_edges, max(0.0, rectangle[\"left\"]), rectangle[\"right\"])\n if not rows or not columns:\n return None\n return f\"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}\"\n\n\ndef _has_content(cell: Any) -> bool:\n if not isinstance(cell, dict):\n return False\n for key in (\"value\", \"formula\", \"note\"):\n value = cell.get(key)\n if value not in (None, \"\"):\n return True\n return bool(cell.get(\"rich_text\") or cell.get(\"multiple_values\"))\n\n\ndef _iter_cells(data: dict[str, Any]):\n ranges = data.get(\"ranges\")\n if not isinstance(ranges, list):\n return\n for result_range in ranges:\n if not isinstance(result_range, dict):\n continue\n cells = result_range.get(\"cells\")\n rows = result_range.get(\"row_indices\")\n columns = result_range.get(\"col_indices\")\n if not isinstance(cells, list):\n continue\n for row_offset, row in enumerate(cells):\n if not isinstance(row, list):\n continue\n row_number = int(rows[row_offset]) if isinstance(rows, list) and row_offset \u003c len(rows) else row_offset + 1\n for column_offset, cell in enumerate(row):\n column = columns[column_offset] if isinstance(columns, list) and column_offset \u003c len(columns) else index_to_column(column_offset)\n yield row_number, column_to_index(str(column)), cell\n\n\ndef non_empty_cells(\n data: dict[str, Any], sample_limit: int, bounds: CellBounds | None = None\n) -> tuple[int, list[str], bool]:\n count = 0\n samples: list[str] = []\n truncated = bool(data.get(\"has_more\"))\n ranges = data.get(\"ranges\")\n if not isinstance(ranges, list):\n return 0, [], truncated\n for result_range in ranges:\n if not isinstance(result_range, dict):\n continue\n truncated = truncated or bool(result_range.get(\"truncated\"))\n for row_number, column_index, cell in _iter_cells(data):\n if bounds and not (\n bounds[0] \u003c= row_number \u003c= bounds[1]\n and bounds[2] \u003c= column_index \u003c= bounds[3]\n ):\n continue\n if not _has_content(cell):\n continue\n count += 1\n if len(samples) \u003c sample_limit:\n samples.append(f\"{index_to_column(column_index)}{row_number}\")\n return count, samples, truncated\n\n\ndef _read_cells(\n cache: CellCache,\n locator: dict[str, str],\n *,\n sheet_id: str | None,\n sheet_name: str | None,\n cell_range: str,\n include: str,\n skip_hidden: bool = False,\n timeout: int,\n) -> dict[str, Any]:\n selector = f\"id:{sheet_id}\" if sheet_id else f\"name:{sheet_name}\"\n key = (selector, cell_range, include, skip_hidden)\n if key not in cache:\n flags: dict[str, Any] = {\"range\": cell_range, \"include\": include}\n if skip_hidden:\n flags[\"skip-hidden\"] = True\n cache[key] = envelope_data(\n run_sheets(\n \"+cells-get\",\n **locator,\n **({\"sheet_id\": sheet_id} if sheet_id else {\"sheet_name\": sheet_name}),\n flags=flags,\n timeout=timeout,\n )\n )\n return cache[key]\n\n\ndef _read_typed_table(\n cache: CellCache,\n locator: dict[str, str],\n *,\n sheet_id: str | None,\n sheet_name: str | None,\n cell_range: str,\n timeout: int,\n) -> tuple[list[list[Any]], list[str], bool]:\n selector = f\"id:{sheet_id}\" if sheet_id else f\"name:{sheet_name}\"\n key = (selector, cell_range, \"typed_table\", False)\n if key not in cache:\n cache[key] = envelope_data(\n run_sheets(\n \"+table-get\",\n **locator,\n **({\"sheet_id\": sheet_id} if sheet_id else {\"sheet_name\": sheet_name}),\n flags={\"range\": cell_range, \"no-header\": True},\n timeout=timeout,\n )\n )\n data = cache[key]\n sheets = data.get(\"sheets\")\n if not isinstance(sheets, list) or len(sheets) != 1 or not isinstance(sheets[0], dict):\n return [], [], True\n table = sheets[0]\n rows = table.get(\"data\")\n columns = table.get(\"columns\")\n dtypes = table.get(\"dtypes\")\n if not isinstance(rows, list) or not isinstance(columns, list) or not isinstance(dtypes, dict):\n return [], [], True\n def value_kind(dtype: Any) -> str:\n value = str(dtype or \"\").lower()\n if value in {\"number\", \"int64\", \"float64\"}:\n return \"number\"\n if value in {\"bool\", \"boolean\"}:\n return \"bool\"\n if value.startswith(\"datetime\"):\n return \"date\"\n return \"string\"\n\n types = [value_kind(dtypes.get(str(column))) for column in columns]\n truncated = bool(data.get(\"truncated\")) or bool(table.get(\"truncated\"))\n return rows, types, truncated\n\n\ndef _typed_cell(\n rows: list[list[Any]],\n column_types: list[str],\n row_offset: int,\n column_offset: int,\n visible_offsets: set[tuple[int, int]],\n) -> tuple[Any, str]:\n if (\n row_offset \u003c 0\n or row_offset >= len(rows)\n or not isinstance(rows[row_offset], list)\n or column_offset \u003c 0\n or column_offset >= len(rows[row_offset])\n ):\n return None, \"\"\n value = rows[row_offset][column_offset]\n if isinstance(value, bool):\n return value, \"bool\"\n if isinstance(value, (int, float)):\n return value, \"number\"\n kind = column_types[column_offset] if column_offset \u003c len(column_types) else \"\"\n if kind != \"string\" or not isinstance(value, str) or not _looks_numeric(value):\n return value, kind\n\n # table-get infers one dtype per physical column. A hidden cell or another\n # row-series can widen that dtype and stringify otherwise numeric cells.\n # Treat such cells as unknown instead of reporting a false storage issue.\n numeric_string_count = 0\n for other_row_offset, row in enumerate(rows):\n if not isinstance(row, list) or column_offset >= len(row):\n continue\n other = row[column_offset]\n if other not in (None, \"\") and (other_row_offset, column_offset) not in visible_offsets:\n return value, \"unknown\"\n if other in (None, \"\") or (\n isinstance(other, (int, float)) and not isinstance(other, bool)\n ):\n continue\n if isinstance(other, str) and _looks_numeric(other):\n numeric_string_count += 1\n continue\n return value, \"unknown\"\n return value, \"string\" if numeric_string_count == 1 else \"ambiguous_string\"\n\n\ndef _numeric_source_format_reason(\n value: Any,\n raw_type: str,\n number_format: Any,\n) -> tuple[str, bool]:\n if not _looks_numeric(str(value or \"\")):\n return \"\", False\n if str(number_format or \"\").strip() == \"@\":\n return \"numeric_value_uses_text_format\", False\n if raw_type == \"string\":\n return \"numeric_value_stored_as_text\", False\n return \"\", raw_type in {\"ambiguous_string\", \"unknown\"}\n\n\ndef _chart_snapshot(chart: dict[str, Any]) -> dict[str, Any]:\n details = chart.get(\"details\") if isinstance(chart.get(\"details\"), dict) else chart\n snapshot = details.get(\"snapshot\")\n return snapshot if isinstance(snapshot, dict) else {}\n\n\ndef _chart_type(snapshot: dict[str, Any]) -> str:\n plot_area = snapshot.get(\"plotArea\")\n plot = plot_area.get(\"plot\") if isinstance(plot_area, dict) else None\n return str(plot.get(\"type\") or \"\").lower() if isinstance(plot, dict) else \"\"\n\n\ndef _plot(snapshot: dict[str, Any]) -> dict[str, Any]:\n plot_area = snapshot.get(\"plotArea\")\n plot = plot_area.get(\"plot\") if isinstance(plot_area, dict) else None\n return plot if isinstance(plot, dict) else {}\n\n\ndef _static_series_profiles(snapshot: dict[str, Any]) -> list[SeriesProfile]:\n data = snapshot.get(\"data\")\n dim2 = data.get(\"dim2\") if isinstance(data, dict) else None\n fields = dim2.get(\"fields\") if isinstance(dim2, dict) else None\n if not isinstance(fields, list):\n return []\n profiles: list[SeriesProfile] = []\n for offset, field in enumerate(fields, start=1):\n if not isinstance(field, dict):\n continue\n values = []\n for value in field.get(\"parsedValues\") or []:\n numeric = (\n float(value)\n if isinstance(value, (int, float)) and not isinstance(value, bool)\n else _numeric_text_value(value) if isinstance(value, str) else None\n )\n if numeric is not None:\n values.append(numeric)\n profiles.append(\n {\n \"dimension_index\": offset,\n \"series_name\": str(field.get(\"name\") or f\"Series {offset}\"),\n \"point_count\": len(field.get(\"parsedValues\") or []),\n \"numeric_value_count\": len(values),\n \"unique_numeric_values\": list(dict.fromkeys(values))[:2],\n \"source_sheet\": \"\",\n \"source_range\": \"\",\n \"series_range\": \"\",\n }\n )\n return profiles\n\n\ndef _labeled_series_indexes(\n snapshot: dict[str, Any], profiles: list[SeriesProfile]\n) -> set[int]:\n plot = _plot(snapshot)\n available = {\n int(profile[\"dimension_index\"])\n for profile in profiles\n if profile.get(\"dimension_index\") is not None\n }\n labeled = set(available) if isinstance(plot.get(\"labels\"), dict) else set()\n series = plot.get(\"series\")\n if isinstance(series, list):\n labeled.update(\n int(item[\"index\"])\n for item in series\n if isinstance(item, dict)\n and item.get(\"index\") is not None\n and isinstance(item.get(\"labels\"), dict)\n )\n return labeled & available\n\n\ndef _constant_labeled_series(\n chart: dict[str, Any], profiles: list[SeriesProfile]\n) -> list[dict[str, Any]]:\n chart_id = str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\")\n snapshot = _chart_snapshot(chart)\n labeled = _labeled_series_indexes(snapshot, profiles)\n return [\n {\n \"chart_id\": chart_id,\n \"dimension_index\": profile[\"dimension_index\"],\n \"series_name\": profile[\"series_name\"],\n \"source_sheet\": profile[\"source_sheet\"],\n \"source_range\": profile[\"source_range\"],\n \"series_range\": profile[\"series_range\"],\n \"reason\": \"constant_labeled_series\",\n \"data_point_count\": profile[\"point_count\"],\n \"constant_value\": profile[\"unique_numeric_values\"][0],\n \"suggested_fix\": \"remove_series_labels_or_use_one_sparse_marker\",\n }\n for profile in profiles\n if int(profile.get(\"dimension_index\", -1)) in labeled\n and profile.get(\"constant_check_unverifiable\") is not True\n and int(profile.get(\"numeric_value_count\", 0)) >= 2\n and len(profile.get(\"unique_numeric_values\") or []) == 1\n ]\n\n\ndef _unbound_secondary_axis(chart: dict[str, Any]) -> dict[str, Any] | None:\n snapshot = _chart_snapshot(chart)\n plot_area = snapshot.get(\"plotArea\")\n if not isinstance(plot_area, dict):\n return None\n plot = plot_area.get(\"plot\")\n if not isinstance(plot, dict) or str(plot.get(\"type\") or \"\").lower() != \"combo\":\n return None\n\n axes = plot_area.get(\"axes\")\n has_right_axis = isinstance(axes, list) and any(\n isinstance(axis, dict)\n and str(axis.get(\"type\") or \"\").lower() == \"y\"\n and str(axis.get(\"position\") or axis.get(\"axisPosition\") or \"\").lower() == \"right\"\n for axis in axes\n )\n series = plot.get(\"series\")\n configured_series = (\n [item for item in series if isinstance(item, dict)]\n if isinstance(series, list)\n else []\n )\n if not has_right_axis or not configured_series:\n return None\n if str(plot.get(\"yAxisPosition\") or \"\").lower() == \"right\" or any(\n str(item.get(\"yAxisPosition\") or \"\").lower() == \"right\"\n for item in configured_series\n ):\n return None\n\n return {\n \"chart_id\": str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\"),\n \"reason\": \"secondary_axis_has_no_bound_series\",\n \"series_indexes\": [\n int(item[\"index\"])\n for item in configured_series\n if isinstance(item.get(\"index\"), (int, float))\n ],\n \"suggested_fix\": \"bind_the_intended_combo_series_to_the_right_axis\",\n }\n\n\ndef _undersized_chart(chart: dict[str, Any]) -> dict[str, Any] | None:\n details = chart.get(\"details\") if isinstance(chart.get(\"details\"), dict) else chart\n snapshot = _chart_snapshot(chart)\n chart_type = _chart_type(snapshot)\n size = details.get(\"size\")\n if not isinstance(size, dict):\n return None\n width = size.get(\"width\")\n height = size.get(\"height\")\n if (\n not isinstance(width, (int, float))\n or isinstance(width, bool)\n or width \u003c= 0\n or not isinstance(height, (int, float))\n or isinstance(height, bool)\n or height \u003c= 0\n ):\n return None\n actual = {\n \"width\": float(width),\n \"height\": float(height),\n }\n minimum = minimum_chart_size(chart_type)\n if actual[\"width\"] >= minimum[\"width\"] and actual[\"height\"] >= minimum[\"height\"]:\n return None\n return {\n \"chart_id\": str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\"),\n \"reason\": \"chart_below_minimum_size\",\n \"chart_type\": chart_type,\n \"actual_size\": actual,\n \"minimum_size\": minimum,\n \"suggested_fix\": \"run_lark_chart_size_advisor_before_resizing\",\n }\n\n\ndef _overwide_chart(chart: dict[str, Any]) -> dict[str, Any] | None:\n details = chart.get(\"details\") if isinstance(chart.get(\"details\"), dict) else chart\n snapshot = _chart_snapshot(chart)\n chart_type = _chart_type(snapshot)\n size = details.get(\"size\") if isinstance(details.get(\"size\"), dict) else {}\n width = size.get(\"width\")\n height = size.get(\"height\")\n if (\n not isinstance(width, (int, float))\n or isinstance(width, bool)\n or width \u003c= 0\n or not isinstance(height, (int, float))\n or isinstance(height, bool)\n or height \u003c= 0\n ):\n return None\n width = float(width)\n height = float(height)\n minimum = minimum_chart_size(chart_type)\n aspect_ratio = width / height if height > 0 else 0\n width_exceeded = width > MAX_CHART_WIDTH\n aspect_ratio_exceeded = (\n width >= minimum[\"width\"]\n and height >= minimum[\"height\"]\n and aspect_ratio > MAX_ASPECT_RATIO\n )\n if not width_exceeded and not aspect_ratio_exceeded:\n return None\n return {\n \"chart_id\": str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\"),\n \"reason\": \"chart_too_wide\",\n \"chart_type\": chart_type,\n \"actual_size\": {\"width\": width, \"height\": height},\n \"actual_aspect_ratio\": round(aspect_ratio, 2),\n \"maximum_width\": MAX_CHART_WIDTH,\n \"maximum_aspect_ratio\": MAX_ASPECT_RATIO,\n \"suggested_fix\": \"use_size_advisor_create_flags_or_change_chart_structure\",\n }\n\n\ndef _numeric_dimensions(snapshot: dict[str, Any]) -> list[tuple[int, str]]:\n data = snapshot.get(\"data\")\n if not isinstance(data, dict):\n return []\n dim2 = data.get(\"dim2\")\n series = dim2.get(\"series\") if isinstance(dim2, dict) else None\n chart_type = _chart_type(snapshot)\n dimensions: list[tuple[int, str]] = []\n if isinstance(series, list):\n for offset, serie in enumerate(series):\n if not isinstance(serie, dict) or serie.get(\"index\") is None:\n continue\n if str(serie.get(\"aggregateType\") or \"\").lower() == \"counta\":\n continue\n role = str(serie.get(\"role\") or \"\").lower()\n if chart_type == \"bubble\":\n role = role or (\"x\", \"y\", \"group\", \"size\")[min(offset, 3)]\n if role not in {\"x\", \"y\", \"size\"}:\n continue\n dimensions.append((int(serie[\"index\"]), role or \"value\"))\n\n plot_area = snapshot.get(\"plotArea\")\n axes = plot_area.get(\"axes\") if isinstance(plot_area, dict) else None\n continuous_x = chart_type == \"scatter\"\n if isinstance(axes, list):\n for axis in axes:\n if not isinstance(axis, dict) or str(axis.get(\"type\") or \"\").lower() != \"x\":\n continue\n position = axis.get(\"position\")\n if (\n (position is None or str(position).lower() in {\"bottom\", \"x\"})\n and str(axis.get(\"valueType\") or \"\").lower() == \"linear\"\n ):\n continuous_x = True\n break\n dim1 = data.get(\"dim1\")\n serie = dim1.get(\"serie\") if isinstance(dim1, dict) else None\n if continuous_x and chart_type != \"bubble\" and isinstance(serie, dict) and serie.get(\"index\") is not None:\n dimensions.append((int(serie[\"index\"]), \"x\"))\n\n return list(dict.fromkeys(dimensions))\n\n\ndef _series_aggregate_type(data: dict[str, Any], dimension_index: int) -> str:\n dim2 = data.get(\"dim2\")\n value_series = dim2.get(\"series\") if isinstance(dim2, dict) else None\n source_series = next(\n (\n item\n for item in (value_series if isinstance(value_series, list) else [])\n if isinstance(item, dict)\n and item.get(\"index\") is not None\n and int(item[\"index\"]) == dimension_index\n ),\n {},\n )\n return str(source_series.get(\"aggregateType\") or \"sum\").lower()\n\n\ndef _aggregation_can_change_constant(data: dict[str, Any], dimension_index: int) -> bool:\n dim1 = data.get(\"dim1\")\n category_series = dim1.get(\"serie\") if isinstance(dim1, dict) else None\n if not isinstance(category_series, dict) or category_series.get(\"aggregate\") is False:\n return False\n return _series_aggregate_type(data, dimension_index) in {\"sum\", \"count\", \"counta\"}\n\n\ndef _parse_chart_ref(value: str, default_sheet: str) -> tuple[str, str, CellBounds]:\n raw = str(value).strip()\n sheet_name = default_sheet\n cell_range = raw\n if \"!\" in raw:\n sheet_name, cell_range = raw.rsplit(\"!\", 1)\n sheet_name = sheet_name.strip()\n if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == \"'\":\n sheet_name = sheet_name[1:-1].replace(\"''\", \"'\")\n return sheet_name, cell_range.replace(\"$\", \"\"), _parse_a1_bounds(cell_range)\n\n\ndef _looks_numeric(value: str) -> bool:\n return _numeric_text_value(value) is not None\n\n\ndef _numeric_text_value(value: str) -> float | None:\n text = value.strip()\n if not text:\n return None\n text = re.sub(r\"^([+-]?)[\\$\\u00a5\\uffe5\\u20ac\\u00a3]\", r\"\\1\", text)\n if text.endswith(\"%\"):\n text = text[:-1]\n # Group separators must be consistent and separate groups of three digits.\n if not re.fullmatch(\n r\"[+-]?(?:(?:\\d+|\\d{1,3}([, ])\\d{3}(?:\\1\\d{3})*)(?:\\.\\d*)?|\\.\\d+)\"\n r\"(?:[eE][+-]?\\d+)?\",\n text,\n ):\n return None\n return float(text.replace(\" \", \"\").replace(\",\", \"\"))\n\n\ndef _zero_state(value: Any) -> str:\n if value in (None, \"\"):\n return \"empty\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return \"zero\" if value == 0 else \"nonzero\"\n if isinstance(value, str):\n numeric_value = _numeric_text_value(value)\n if numeric_value is not None:\n return \"zero\" if numeric_value == 0 else \"nonzero\"\n return \"nonzero\"\n\n\ndef _update_series_state(state: dict[str, Any], value: Any) -> None:\n state[_zero_state(value)] = True\n numeric = (\n float(value)\n if isinstance(value, (int, float)) and not isinstance(value, bool)\n else _numeric_text_value(value) if isinstance(value, str) else None\n )\n if numeric is None:\n return\n state[\"numeric_value_count\"] += 1\n if len(state[\"unique_numeric_values\"]) \u003c 2:\n state[\"unique_numeric_values\"].add(numeric)\n\n\ndef _cells_truncated(data: dict[str, Any]) -> bool:\n ranges = data.get(\"ranges\")\n return bool(data.get(\"has_more\")) or any(\n isinstance(item, dict) and item.get(\"truncated\")\n for item in (ranges if isinstance(ranges, list) else [])\n )\n\n\ndef _numeric_source_issues(\n chart: dict[str, Any],\n *,\n owner_sheet_id: str,\n owner_sheet_name: str,\n cache: CellCache,\n locator: dict[str, str],\n timeout: int,\n sample_limit: int,\n) -> tuple[\n list[dict[str, Any]],\n list[dict[str, Any]],\n list[dict[str, str]],\n list[SeriesProfile],\n]:\n chart_id = str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\")\n snapshot = _chart_snapshot(chart)\n data = snapshot.get(\"data\")\n if not isinstance(data, dict):\n return [], [], [{\"chart_id\": chart_id, \"reason\": \"chart snapshot.data is missing\"}], []\n if data.get(\"isStaticData\") is True:\n return [], [], [], _static_series_profiles(snapshot)\n dimensions = _numeric_dimensions(snapshot)\n if not dimensions:\n return [], [], [], []\n refs = data.get(\"refs\")\n if not isinstance(refs, list) or not refs:\n return [], [], [{\"chart_id\": chart_id, \"reason\": \"chart data.refs is missing\"}], []\n\n parsed_refs: list[tuple[str, str, CellBounds]] = []\n unverifiable: list[dict[str, str]] = []\n for ref in refs:\n raw_ref = ref.get(\"value\") if isinstance(ref, dict) else ref\n try:\n parsed_refs.append(_parse_chart_ref(str(raw_ref), owner_sheet_name))\n except (TypeError, ValueError) as exc:\n unverifiable.append({\"chart_id\": chart_id, \"reason\": str(exc)})\n return [], [], unverifiable, []\n\n direction = str(data.get(\"direction\") or \"column\").lower()\n skip_hidden = data.get(\"includeHiddenOrFilter\") is not True\n mapped: list[tuple[int, str, int, str, str, CellBounds]] = []\n for dimension_index, role in dimensions:\n offset = 0\n for source_sheet, source_range, bounds in parsed_refs:\n dimension_count = (\n bounds[3] - bounds[2] + 1 if direction == \"column\" else bounds[1] - bounds[0] + 1\n )\n if offset \u003c dimension_index \u003c= offset + dimension_count:\n mapped.append(\n (\n dimension_index,\n role,\n dimension_index - offset,\n source_sheet,\n source_range,\n bounds,\n )\n )\n break\n offset += dimension_count\n else:\n unverifiable.append(\n {\n \"chart_id\": chart_id,\n \"reason\": f\"numeric dimension index {dimension_index} is outside data.refs\",\n }\n )\n\n detached = str(data.get(\"headerMode\") or \"\").lower() == \"detached\"\n mapped_by_ref: dict[\n tuple[str, str, CellBounds], list[tuple[int, str, int]]\n ] = {}\n for dimension_index, role, local_index, source_sheet, source_range, bounds in mapped:\n mapped_by_ref.setdefault((source_sheet, source_range, bounds), []).append(\n (dimension_index, role, local_index)\n )\n\n issue_groups: dict[tuple[int, str, str, str, str, str], list[str]] = {}\n issue_counts: dict[tuple[int, str, str, str, str, str], int] = {}\n degenerate_series: list[dict[str, Any]] = []\n series_profiles: list[SeriesProfile] = []\n remaining_source_cells = MAX_CELL_READ_SIZE\n remaining_dimension_spans = sum(\n max(local_index for _, _, local_index in ref_dimensions)\n - min(local_index for _, _, local_index in ref_dimensions)\n + 1\n for ref_dimensions in mapped_by_ref.values()\n )\n for (source_sheet, source_range, bounds), ref_dimensions in mapped_by_ref.items():\n dimension_start = bounds[2] if direction == \"column\" else bounds[0]\n selected = {\n dimension_start + local_index - 1: (dimension_index, role)\n for dimension_index, role, local_index in ref_dimensions\n }\n dimension_span = max(selected) - min(selected) + 1\n header_points = 0 if detached else 1\n # Each sampled rectangle is read twice: cells-get supplies coordinates,\n # styles, and hidden/filter semantics; table-get supplies typed values.\n cells_per_dimension = remaining_source_cells // remaining_dimension_spans\n remaining_dimension_spans -= dimension_span\n point_axis_size = (\n bounds[1] - bounds[0] + 1\n if direction == \"column\"\n else bounds[3] - bounds[2] + 1\n )\n if point_axis_size \u003c= header_points:\n unverifiable.append(\n {\n \"chart_id\": chart_id,\n \"reason\": f\"source has no data points: {source_sheet}!{source_range}\",\n }\n )\n continue\n sample_points = min(MAX_SOURCE_SAMPLE_POINTS, max(0, (cells_per_dimension - header_points) // 2))\n if sample_points == 0:\n unverifiable.append({\n \"chart_id\": chart_id,\n \"reason\": f\"source sampling 2000-cell budget cannot cover {source_sheet}!{source_range}\",\n })\n continue\n point_count = sample_points + header_points\n if direction == \"column\":\n checked_bounds = (\n bounds[0],\n min(bounds[1], bounds[0] + point_count - 1),\n min(selected),\n max(selected),\n )\n else:\n checked_bounds = (\n min(selected),\n max(selected),\n bounds[2],\n min(bounds[3], bounds[2] + point_count - 1),\n )\n typed_bounds = (\n (checked_bounds[0] + header_points, checked_bounds[1], checked_bounds[2], checked_bounds[3])\n if direction == \"column\"\n else (checked_bounds[0], checked_bounds[1], checked_bounds[2] + header_points, checked_bounds[3])\n )\n remaining_source_cells -= _bounds_area(checked_bounds) + _bounds_area(typed_bounds)\n checked_range = _format_a1_bounds(checked_bounds)\n same_sheet = source_sheet == owner_sheet_name\n cells_data = _read_cells(\n cache,\n locator,\n sheet_id=owner_sheet_id if same_sheet else None,\n sheet_name=None if same_sheet else source_sheet,\n cell_range=checked_range,\n include=\"value,style\",\n skip_hidden=skip_hidden,\n timeout=timeout,\n )\n cells_truncated = _cells_truncated(cells_data)\n typed_rows, typed_columns, table_truncated = _read_typed_table(\n cache,\n locator,\n sheet_id=owner_sheet_id if same_sheet else None,\n sheet_name=None if same_sheet else source_sheet,\n cell_range=_format_a1_bounds(typed_bounds),\n timeout=timeout,\n )\n if cells_truncated:\n unverifiable.append(\n {\"chart_id\": chart_id, \"reason\": f\"cells-get truncated for {source_sheet}!{checked_range}\"}\n )\n if table_truncated:\n unverifiable.append(\n {\n \"chart_id\": chart_id,\n \"reason\": (\n \"table-get truncated or returned invalid typed data for \"\n f\"{source_sheet}!{_format_a1_bounds(typed_bounds)}\"\n ),\n }\n )\n truncated = cells_truncated or table_truncated\n states = {\n coordinate: {\n \"zero\": False,\n \"empty\": False,\n \"nonzero\": False,\n \"sample_point_count\": 0,\n \"numeric_value_count\": 0,\n \"unique_numeric_values\": set(),\n }\n for coordinate in selected\n }\n type_unverifiable_dimensions: set[int] = set()\n visible_offsets = {\n (row_number - typed_bounds[0], column_index - typed_bounds[2])\n for row_number, column_index, _ in _iter_cells(cells_data)\n if typed_bounds[0] \u003c= row_number \u003c= typed_bounds[1]\n and typed_bounds[2] \u003c= column_index \u003c= typed_bounds[3]\n }\n for row_number, column_index, cell in _iter_cells(cells_data):\n coordinate = column_index if direction == \"column\" else row_number\n dimension = selected.get(coordinate)\n if dimension is None:\n continue\n if not detached and (\n (direction == \"column\" and row_number == bounds[0])\n or (direction != \"column\" and column_index == bounds[2])\n ):\n continue\n states[coordinate][\"sample_point_count\"] += 1\n row_offset = row_number - typed_bounds[0]\n column_offset = column_index - typed_bounds[2]\n value, raw_type = _typed_cell(\n typed_rows,\n typed_columns,\n row_offset,\n column_offset,\n visible_offsets,\n )\n _update_series_state(states[coordinate], value)\n number_format = (\n cell.get(\"cell_styles\", {}).get(\"number_format\")\n if isinstance(cell, dict) and isinstance(cell.get(\"cell_styles\"), dict)\n else None\n )\n reason, type_unverifiable = _numeric_source_format_reason(\n value,\n raw_type,\n number_format,\n )\n if type_unverifiable:\n type_unverifiable_dimensions.add(dimension[0])\n if not reason:\n continue\n dimension_index, role = dimension\n key = (\n dimension_index,\n role,\n source_sheet,\n source_range,\n checked_range,\n reason,\n )\n issue_counts[key] = issue_counts.get(key, 0) + 1\n samples = issue_groups.setdefault(key, [])\n if len(samples) \u003c sample_limit:\n samples.append(f\"{index_to_column(column_index)}{row_number}\")\n\n if truncated:\n continue\n for dimension_index in sorted(type_unverifiable_dimensions):\n unverifiable.append(\n {\n \"chart_id\": chart_id,\n \"reason\": (\n \"numeric storage type cannot be attributed to individual cells after \"\n f\"typed column coercion for {source_sheet}!{checked_range}, \"\n f\"dimension {dimension_index}\"\n ),\n }\n )\n zero_candidates = {\n coordinate\n for coordinate, state in states.items()\n if not state[\"nonzero\"]\n and _series_aggregate_type(data, selected[coordinate][0]) != \"count\"\n }\n data_start = bounds[0] + (0 if detached else 1)\n data_column = bounds[2] + (0 if detached else 1)\n dim2 = data.get(\"dim2\")\n value_series = dim2.get(\"series\") if isinstance(dim2, dict) else None\n for coordinate, state in states.items():\n dimension_index, role = selected[coordinate]\n if direction == \"column\":\n point_total = max(0, bounds[1] - data_start + 1)\n series_range = (\n f\"{index_to_column(coordinate)}{data_start}:\"\n f\"{index_to_column(coordinate)}{bounds[1]}\"\n if point_total\n else \"\"\n )\n else:\n point_total = max(0, bounds[3] - data_column + 1)\n series_range = (\n f\"{index_to_column(data_column)}{coordinate}:\"\n f\"{index_to_column(bounds[3])}{coordinate}\"\n if point_total\n else \"\"\n )\n source_series = next(\n (\n item\n for item in (value_series if isinstance(value_series, list) else [])\n if isinstance(item, dict)\n and item.get(\"index\") is not None\n and int(item[\"index\"]) == dimension_index\n ),\n {},\n )\n profile = {\n \"dimension_index\": dimension_index,\n \"series_name\": str(\n source_series.get(\"name\")\n or source_series.get(\"nameRef\")\n or f\"Series {dimension_index}\"\n ),\n \"point_count\": point_total,\n \"sample_point_count\": state[\"sample_point_count\"],\n \"checked_range\": checked_range,\n \"sampled\": (\n checked_bounds[1] \u003c bounds[1]\n if direction == \"column\"\n else checked_bounds[3] \u003c bounds[3]\n ),\n \"numeric_value_count\": state[\"numeric_value_count\"],\n \"unique_numeric_values\": list(state[\"unique_numeric_values\"]),\n \"source_sheet\": source_sheet,\n \"source_range\": source_range,\n \"series_range\": series_range,\n }\n aggregate_type = _series_aggregate_type(data, dimension_index)\n if aggregate_type == \"count\":\n profile[\"constant_check_unverifiable\"] = True\n constant_labeled = (\n aggregate_type != \"count\"\n and state[\"numeric_value_count\"] >= 2\n and len(state[\"unique_numeric_values\"]) == 1\n and dimension_index\n in _labeled_series_indexes(snapshot, [{\"dimension_index\": dimension_index}])\n )\n if profile[\"sampled\"]:\n profile[\"constant_check_unverifiable\"] = True\n if coordinate in zero_candidates or constant_labeled:\n unverifiable.append({\n \"chart_id\": chart_id,\n \"reason\": (\n \"zero/constant-series check is unverifiable outside sampled source \"\n f\"{source_sheet}!{checked_range} for dimension {dimension_index}\"\n ),\n })\n elif constant_labeled and _aggregation_can_change_constant(data, dimension_index):\n profile[\"constant_check_unverifiable\"] = True\n unverifiable.append(\n {\n \"chart_id\": chart_id,\n \"reason\": (\n \"constant-series check is unverifiable after category \"\n f\"aggregation for dimension {dimension_index}\"\n ),\n }\n )\n series_profiles.append(profile)\n if coordinate not in zero_candidates or profile[\"sampled\"]:\n continue\n degenerate_series.append(\n {\n \"chart_id\": chart_id,\n \"dimension_index\": dimension_index,\n \"role\": role,\n \"source_sheet\": source_sheet,\n \"source_range\": source_range,\n \"series_range\": series_range,\n \"reason\": (\n \"numeric_series_all_zero_or_empty\"\n if states[coordinate][\"zero\"]\n else \"numeric_series_all_empty\"\n ),\n \"data_point_count\": point_total,\n }\n )\n\n issues = [\n {\n \"chart_id\": chart_id,\n \"dimension_index\": key[0],\n \"role\": key[1],\n \"source_sheet\": key[2],\n \"source_range\": key[3],\n \"checked_range\": key[4],\n \"reason\": key[5],\n \"suggested_fix\": (\n \"set_numeric_number_format\"\n if key[5] == \"numeric_value_uses_text_format\"\n else \"rewrite_as_number_and_set_numeric_number_format\"\n ),\n \"affected_sample_cell_count\": issue_counts[key],\n \"sample_cells\": samples,\n }\n for key, samples in issue_groups.items()\n ]\n return issues, degenerate_series, unverifiable, series_profiles\n\n\ndef _locator(target: str) -> dict[str, str]:\n return {\"url\": target} if target.startswith((\"http://\", \"https://\")) else {\"spreadsheet_token\": target}\n\n\ndef _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]:\n row_count = int(sheet.get(\"row_count\") or sheet.get(\"rowCount\") or 0)\n column_count = int(sheet.get(\"column_count\") or sheet.get(\"columnCount\") or 0)\n if row_count \u003c= 0 or column_count \u003c= 0:\n raise LarkCliError(f\"Missing row_count/column_count for sheet {sheet_title(sheet)!r}\")\n return row_count, column_count\n\n\ndef check_sheet(\n locator: dict[str, str],\n sheet: dict[str, Any],\n *,\n timeout: int,\n sample_limit: int,\n cell_cache: CellCache | None = None,\n) -> dict[str, Any]:\n cell_cache = cell_cache if cell_cache is not None else {}\n sheet_id = sheet_identifier(sheet)\n title = sheet_title(sheet)\n if not sheet_id:\n raise LarkCliError(f\"Missing sheet_id for sheet {title!r}\")\n\n chart_data = envelope_data(\n run_sheets(\"+chart-list\", **locator, sheet_id=sheet_id, timeout=timeout)\n )\n charts = extract_charts(chart_data, sheet_id, title)\n unverifiable: list[dict[str, str]] = []\n expected_chart_count = sheet.get(\"chart_count\")\n if expected_chart_count is not None and int(expected_chart_count) != len(charts):\n unverifiable.append(\n {\n \"chart_id\": \"\",\n \"reason\": (\n f\"chart-list returned {len(charts)} charts, \"\n f\"but workbook-info reported {int(expected_chart_count)}\"\n ),\n }\n )\n if not charts:\n return {\n \"sheet_id\": sheet_id,\n \"sheet_name\": title,\n \"chart_count\": 0,\n \"sheet_size_px\": None,\n \"chart_overlaps\": [],\n \"cell_content_overlaps\": [],\n \"numeric_source_format_issues\": [],\n \"numeric_source_samples\": [],\n \"degenerate_numeric_series\": [],\n \"constant_labeled_series\": [],\n \"unbound_secondary_axes\": [],\n \"undersized_charts\": [],\n \"overwide_charts\": [],\n \"out_of_visible_range\": [],\n \"unverifiable_charts\": unverifiable,\n \"issue_count\": 0,\n \"unverifiable_count\": len(unverifiable),\n \"warnings\": [],\n }\n\n row_count, column_count = _sheet_counts(sheet)\n structure_data = envelope_data(\n run_sheets(\n \"+sheet-info\",\n **locator,\n sheet_id=sheet_id,\n flags={\"include\": \"row_heights,col_widths\"},\n timeout=timeout,\n )\n )\n row_edges, column_edges, warnings = build_layout(\n extract_sheet_structure(structure_data), row_count, column_count\n )\n\n rectangles: list[dict[str, Any]] = []\n for chart in charts:\n chart_id = str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\")\n if not chart_id:\n unverifiable.append({\"chart_id\": \"\", \"reason\": \"chart is missing chart_id\"})\n continue\n try:\n rectangles.append(chart_rectangle(chart, row_edges, column_edges))\n except (KeyError, TypeError, ValueError) as exc:\n unverifiable.append({\"chart_id\": chart_id, \"reason\": str(exc)})\n\n overlaps: list[dict[str, Any]] = []\n for index, first in enumerate(rectangles):\n for second in rectangles[index + 1 :]:\n overlap = intersection(first, second)\n if overlap:\n overlaps.append(\n {\n \"chart_ids\": [first[\"chart_id\"], second[\"chart_id\"]],\n \"charts\": [chart_context(first), chart_context(second)],\n \"intersection\": overlap,\n }\n )\n\n sheet_width = column_edges[-1]\n sheet_height = row_edges[-1]\n out_of_bounds: list[dict[str, Any]] = []\n content_overlaps: list[dict[str, Any]] = []\n covered_items: list[tuple[dict[str, Any], CellBounds]] = []\n for rectangle in rectangles:\n overflow = {\n \"left\": round(max(0.0, -rectangle[\"left\"]), 2),\n \"top\": round(max(0.0, -rectangle[\"top\"]), 2),\n \"right\": round(max(0.0, rectangle[\"right\"] - sheet_width), 2),\n \"bottom\": round(max(0.0, rectangle[\"bottom\"] - sheet_height), 2),\n }\n if any(overflow.values()):\n out_of_bounds.append({**chart_context(rectangle), \"overflow_px\": overflow})\n\n covered_range = rectangle_cell_range(rectangle, row_edges, column_edges)\n if not covered_range:\n continue\n covered_items.append((rectangle, _parse_a1_bounds(covered_range)))\n\n for cluster in _cluster_cell_reads(covered_items):\n read_range = _format_a1_bounds(cluster[\"bounds\"])\n cells_data = _read_cells(\n cell_cache,\n locator,\n sheet_id=sheet_id,\n sheet_name=None,\n cell_range=read_range,\n include=\"value,formula,comment\",\n timeout=timeout,\n )\n for rectangle, bounds in cluster[\"members\"]:\n covered_range = _format_a1_bounds(bounds)\n count, samples, truncated = non_empty_cells(cells_data, sample_limit, bounds)\n if truncated:\n unverifiable.append(\n {\n \"chart_id\": rectangle[\"chart_id\"],\n \"reason\": f\"cells-get truncated for {read_range}\",\n }\n )\n if count:\n content_overlaps.append(\n {\n **chart_context(rectangle),\n \"covered_range\": covered_range,\n \"non_empty_cell_count\": count,\n \"sample_cells\": samples,\n }\n )\n\n numeric_source_issues: list[dict[str, Any]] = []\n numeric_source_samples: list[dict[str, Any]] = []\n degenerate_numeric_series: list[dict[str, Any]] = []\n constant_series_issues: list[dict[str, Any]] = []\n unbound_secondary_axes: list[dict[str, Any]] = []\n undersized_charts: list[dict[str, Any]] = []\n overwide_charts: list[dict[str, Any]] = []\n for chart in charts:\n issues, degenerate, source_unverifiable, profiles = _numeric_source_issues(\n chart,\n owner_sheet_id=sheet_id,\n owner_sheet_name=title,\n cache=cell_cache,\n locator=locator,\n timeout=timeout,\n sample_limit=sample_limit,\n )\n numeric_source_issues.extend(issues)\n numeric_source_samples.extend(\n {\"chart_id\": str(chart.get(\"chart_id\") or chart.get(\"id\") or \"\"), **profile}\n for profile in profiles\n if \"checked_range\" in profile\n )\n degenerate_numeric_series.extend(degenerate)\n unverifiable.extend(source_unverifiable)\n constant_series_issues.extend(_constant_labeled_series(chart, profiles))\n unbound_secondary_axis = _unbound_secondary_axis(chart)\n if unbound_secondary_axis:\n unbound_secondary_axes.append(unbound_secondary_axis)\n undersized = _undersized_chart(chart)\n if undersized:\n undersized_charts.append(undersized)\n overwide = _overwide_chart(chart)\n if overwide:\n overwide_charts.append(overwide)\n\n issue_count = (\n len(overlaps)\n + len(out_of_bounds)\n + len(content_overlaps)\n + len(numeric_source_issues)\n + len(degenerate_numeric_series)\n + len(constant_series_issues)\n + len(unbound_secondary_axes)\n + len(undersized_charts)\n + len(overwide_charts)\n )\n return {\n \"sheet_id\": sheet_id,\n \"sheet_name\": title,\n \"chart_count\": len(charts),\n \"sheet_size_px\": {\"width\": round(sheet_width, 2), \"height\": round(sheet_height, 2)},\n \"chart_overlaps\": overlaps,\n \"cell_content_overlaps\": content_overlaps,\n \"numeric_source_format_issues\": numeric_source_issues,\n \"numeric_source_samples\": numeric_source_samples,\n \"degenerate_numeric_series\": degenerate_numeric_series,\n \"constant_labeled_series\": constant_series_issues,\n \"unbound_secondary_axes\": unbound_secondary_axes,\n \"undersized_charts\": undersized_charts,\n \"overwide_charts\": overwide_charts,\n \"out_of_visible_range\": out_of_bounds,\n \"unverifiable_charts\": unverifiable,\n \"issue_count\": issue_count,\n \"unverifiable_count\": len(unverifiable),\n \"warnings\": warnings,\n }\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(\n description=(\n \"Check chart overlap, covered cell content, worksheet boundary overflow, \"\n \"minimum size, excessive width, constant labeled series, numeric source-cell \"\n \"formats, all-zero/empty numeric series, and unbound combo-chart secondary axes.\"\n )\n )\n parser.add_argument(\"sheet_id\", help=\"Spreadsheet URL or spreadsheet token\")\n parser.add_argument(\"--worksheet-id\", help=\"Only check this worksheet reference_id\")\n parser.add_argument(\"--timeout\", type=int, default=60)\n parser.add_argument(\"--sample-limit\", type=int, default=10)\n return parser.parse_args()\n\n\ndef success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]:\n issue_count = sum(result[\"issue_count\"] for result in results)\n unverifiable_count = sum(result[\"unverifiable_count\"] for result in results)\n warnings = [\n f\"{result['sheet_name'] or result['sheet_id']}: {warning}\"\n for result in results\n for warning in result[\"warnings\"]\n ]\n return {\n \"ok\": True,\n \"engine\": \"lark\",\n \"action\": ACTION,\n \"data\": {\n \"passed\": issue_count == 0 and unverifiable_count == 0,\n \"scope_note\": (\n \"out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport; \"\n \"numeric source checks sample at most the first 50 data points of each chart value dimension \"\n \"for formats, all-zero/empty, and constant-value checks; zero/constant results are conclusive \"\n \"only when that sample covers the complete series, otherwise they are marked unverifiable\"\n ),\n \"summary\": {\n \"worksheet_count\": len(results),\n \"chart_count\": sum(result[\"chart_count\"] for result in results),\n \"issue_count\": issue_count,\n \"unverifiable_count\": unverifiable_count,\n },\n \"sheets\": results,\n },\n \"warnings\": warnings,\n }\n\n\ndef report_exit_code(report: dict[str, Any]) -> int:\n if report[\"data\"][\"passed\"]:\n return 0\n if report[\"data\"][\"summary\"][\"issue_count\"] > 0:\n return 2\n return 1\n\n\ndef main() -> None:\n args = parse_args()\n locator = _locator(args.sheet_id)\n cell_cache: CellCache = {}\n try:\n workbook_data = envelope_data(\n run_sheets(\"+workbook-info\", **locator, timeout=args.timeout)\n )\n sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id)\n if not args.worksheet_id:\n sheets = [sheet for sheet in sheets if not bool(sheet.get(\"is_hidden\"))]\n if not sheets:\n raise LarkCliError(\"No visible worksheet matched\")\n results = [\n check_sheet(\n locator,\n sheet,\n timeout=args.timeout,\n sample_limit=args.sample_limit,\n cell_cache=cell_cache,\n )\n for sheet in sheets\n ]\n except (LarkCliError, KeyError, TypeError, ValueError) as exc:\n emit_error(ACTION, str(exc))\n raise SystemExit(1) from exc\n\n report = success_envelope(results)\n print(json.dumps(report, ensure_ascii=False, indent=2))\n exit_code = report_exit_code(report)\n if exit_code:\n raise SystemExit(exit_code)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":58598,"content_sha256":"4b9f4f3783dd20fcdc3f4ac88410dc8913f9e4a0bc10f0597179db1c9e241af0"},{"filename":"scripts/lark_chart_size_advisor.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Recommend a Lark Sheet chart size before creating the chart object.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport re\nfrom typing import Any\n\nfrom lark_chart_size_rules import SUPPORTED_CHART_TYPES, recommend_chart_size\nfrom lark_sheet_read_cli import (\n LarkCliError,\n emit_error,\n emit_success,\n envelope_data,\n extract_sheets,\n run_sheets,\n sheet_identifier,\n sheet_title,\n)\n\nACTION = \"chart_size_advisor\"\n\n\ndef _locator(target: str) -> dict[str, str]:\n return {\"url\": target} if target.startswith((\"http://\", \"https://\")) else {\"spreadsheet_token\": target}\n\n\ndef _split_range_refs(value: str) -> list[str]:\n refs: list[str] = []\n start = 0\n quoted = False\n index = 0\n while index \u003c len(value):\n char = value[index]\n if char == \"'\":\n if quoted and index + 1 \u003c len(value) and value[index + 1] == \"'\":\n index += 2\n continue\n quoted = not quoted\n elif char == \",\" and not quoted:\n ref = value[start:index].strip()\n if ref:\n refs.append(ref)\n start = index + 1\n index += 1\n tail = value[start:].strip()\n if tail:\n refs.append(tail)\n if not refs or quoted:\n raise ValueError(f\"Invalid data range: {value!r}\")\n return refs\n\n\ndef _parse_ref(value: str) -> tuple[str | None, str]:\n raw = value.strip()\n sheet_name = None\n cell_range = raw\n if \"!\" in raw:\n sheet_name, cell_range = raw.rsplit(\"!\", 1)\n sheet_name = sheet_name.strip()\n if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == \"'\":\n sheet_name = sheet_name[1:-1].replace(\"''\", \"'\")\n cell_range = cell_range.replace(\"$\", \"\")\n if not re.fullmatch(r\"[A-Za-z]+\\d+:[A-Za-z]+\\d+\", cell_range):\n raise ValueError(f\"Invalid A1 range: {value!r}\")\n return sheet_name, cell_range\n\n\ndef _cell_value(cell: Any) -> Any:\n if not isinstance(cell, dict):\n return None\n return cell.get(\"value\", cell.get(\"raw_value\"))\n\n\ndef _matrix(data: dict[str, Any]) -> list[list[Any]]:\n ranges = data.get(\"ranges\")\n if not isinstance(ranges, list) or not ranges:\n return []\n result = ranges[0]\n cells = result.get(\"cells\") if isinstance(result, dict) else None\n if not isinstance(cells, list):\n return []\n return [\n [_cell_value(cell) for cell in row]\n for row in cells\n if isinstance(row, list)\n ]\n\n\ndef _is_network_timeout(exc: LarkCliError) -> bool:\n text = str(exc).strip()\n try:\n payload = json.loads(text)\n except json.JSONDecodeError:\n payload = None\n if isinstance(payload, dict):\n error = payload.get(\"error\")\n if isinstance(error, dict) and str(error.get(\"subtype\") or \"\").lower() == \"timeout\":\n return True\n lowered = text.lower()\n return \"server time out\" in lowered or \"timed out\" in lowered\n\n\ndef _run_read(\n shortcut: str,\n *,\n stage: str,\n timeout: int,\n **kwargs: Any,\n) -> dict[str, Any]:\n retried = False\n while True:\n try:\n return run_sheets(shortcut, timeout=timeout, **kwargs)\n except LarkCliError as exc:\n if not retried and _is_network_timeout(exc):\n retried = True\n continue\n suffix = \" after one retry\" if retried else \"\"\n raise LarkCliError(f\"{stage} failed{suffix}: {exc}\", cmd=exc.cmd) from exc\n\n\ndef _sheet_selector(\n sheets: list[dict[str, Any]],\n *,\n explicit_name: str | None,\n worksheet_id: str | None,\n worksheet_name: str | None,\n) -> dict[str, str]:\n if explicit_name:\n return {\"sheet_name\": explicit_name}\n if worksheet_id:\n return {\"sheet_id\": worksheet_id}\n if worksheet_name:\n return {\"sheet_name\": worksheet_name}\n if len(sheets) != 1:\n raise LarkCliError(\"Unqualified data ranges require --worksheet-id or --worksheet-name\")\n sheet_id = sheet_identifier(sheets[0])\n if sheet_id:\n return {\"sheet_id\": sheet_id}\n return {\"sheet_name\": sheet_title(sheets[0])}\n\n\ndef _needs_workbook_metadata(\n ranges: list[str | None],\n *,\n worksheet_id: str | None,\n worksheet_name: str | None,\n) -> bool:\n if worksheet_id or worksheet_name:\n return False\n return any(\n _parse_ref(ref)[0] is None\n for value in ranges\n if value\n for ref in _split_range_refs(value)\n )\n\n\ndef _read_ranges(\n locator: dict[str, str],\n sheets: list[dict[str, Any]],\n value: str,\n *,\n worksheet_id: str | None,\n worksheet_name: str | None,\n timeout: int,\n stage_prefix: str = \"data range\",\n) -> list[list[list[Any]]]:\n matrices: list[list[list[Any]]] = []\n for ref in _split_range_refs(value):\n explicit_name, cell_range = _parse_ref(ref)\n selector = _sheet_selector(\n sheets,\n explicit_name=explicit_name,\n worksheet_id=worksheet_id,\n worksheet_name=worksheet_name,\n )\n data = envelope_data(\n _run_read(\n \"+cells-get\",\n stage=f\"{stage_prefix} {ref}\",\n **locator,\n **selector,\n flags={\"range\": cell_range, \"include\": \"value\"},\n timeout=timeout,\n )\n )\n matrix = _matrix(data)\n if not matrix:\n raise LarkCliError(f\"No cells returned for {ref}\")\n matrices.append(matrix)\n return matrices\n\n\ndef _combine(matrices: list[list[list[Any]]], direction: str) -> list[list[Any]]:\n if direction == \"column\":\n row_count = len(matrices[0])\n if any(len(matrix) != row_count for matrix in matrices):\n raise ValueError(\"Column-direction ranges must contain the same number of rows\")\n return [sum((matrix[row] for matrix in matrices), []) for row in range(row_count)]\n column_count = max((len(row) for row in matrices[0]), default=0)\n if any(max((len(row) for row in matrix), default=0) != column_count for matrix in matrices):\n raise ValueError(\"Row-direction ranges must contain the same number of columns\")\n return sum(matrices, [])\n\n\ndef _parse_indexes(value: str | None, *, dimension_count: int, dim1_index: int) -> list[int]:\n indexes = (\n [int(item.strip()) for item in value.split(\",\") if item.strip()]\n if value\n else [index for index in range(1, dimension_count + 1) if index != dim1_index]\n )\n if not indexes or any(index \u003c 1 or index > dimension_count for index in indexes):\n raise ValueError(\"--dim2-indexes contains an out-of-range dimension index\")\n if dim1_index in indexes:\n raise ValueError(\"--dim1-index cannot also appear in --dim2-indexes\")\n return indexes\n\n\ndef _numeric(value: Any) -> float | None:\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return float(value)\n if isinstance(value, str):\n text = value.strip().replace(\",\", \"\")\n if text.endswith(\"%\"):\n text = text[:-1]\n try:\n return float(text)\n except ValueError:\n return None\n return None\n\n\ndef profile_matrix(\n matrix: list[list[Any]],\n *,\n direction: str,\n dim1_index: int,\n dim2_indexes: str | None,\n detached_headers: list[Any] | None = None,\n) -> dict[str, Any]:\n if not matrix:\n raise ValueError(\"Data range is empty\")\n dimension_count = max((len(row) for row in matrix), default=0) if direction == \"column\" else len(matrix)\n if dim1_index \u003c 1 or dim1_index > dimension_count:\n raise ValueError(\"--dim1-index is outside the data range\")\n selected = _parse_indexes(\n dim2_indexes,\n dimension_count=dimension_count,\n dim1_index=dim1_index,\n )\n detached = detached_headers is not None\n if direction == \"column\":\n data_rows = matrix if detached else matrix[1:]\n categories = [row[dim1_index - 1] if len(row) >= dim1_index else None for row in data_rows]\n headers = detached_headers or matrix[0]\n series_names = [str(headers[index - 1]) if len(headers) >= index else f\"Series {index}\" for index in selected]\n first_values = [row[selected[0] - 1] if len(row) >= selected[0] else None for row in data_rows]\n else:\n category_row = matrix[dim1_index - 1]\n categories = category_row if detached else category_row[1:]\n headers = detached_headers or [row[0] if row else None for row in matrix]\n series_names = [str(headers[index - 1]) if len(headers) >= index else f\"Series {index}\" for index in selected]\n first_row = matrix[selected[0] - 1]\n first_values = first_row if detached else first_row[1:]\n nonempty_categories = [value for value in categories if value not in (None, \"\")]\n return {\n \"categories\": nonempty_categories,\n \"series_names\": series_names,\n # 海象运算符要 3.8,CI 的解释器是 3.7:先映射再滤空,语义等价。\n \"values\": [n for n in (_numeric(v) for v in first_values) if n is not None],\n \"dim2_indexes\": selected,\n }\n\n\ndef _header_values(matrices: list[list[list[Any]]], direction: str) -> list[Any]:\n matrix = _combine(matrices, direction)\n if direction == \"column\":\n if len(matrix) != 1:\n raise ValueError(\"Column-direction --header-range must contain one row\")\n return matrix[0]\n if any(len(row) != 1 for row in matrix):\n raise ValueError(\"Row-direction --header-range must contain one column\")\n return [row[0] for row in matrix]\n\n\ndef _boolean_argument(value: str) -> bool:\n normalized = str(value).strip().lower()\n if normalized == \"true\":\n return True\n if normalized == \"false\":\n return False\n raise argparse.ArgumentTypeError(\"expected true or false\")\n\n\ndef _comma_separated_values(value: str) -> list[str]:\n values = [item.strip() for item in str(value).split(\",\")]\n if not values or any(not item for item in values):\n raise argparse.ArgumentTypeError(\"expected a comma-separated list\")\n return values\n\n\ndef _series_y_axes_argument(value: str) -> list[str]:\n values = _comma_separated_values(value)\n invalid = [item for item in values if item not in {\"left\", \"right\"}]\n if invalid:\n raise argparse.ArgumentTypeError(\n f\"unsupported series Y axis {invalid[0]!r}; expected left or right\"\n )\n return values\n\n\ndef parse_args(argv: list[str] | None = None) -> argparse.Namespace:\n parser = argparse.ArgumentParser(description=\"Recommend chart width and height before +chart-create-basic\")\n parser.add_argument(\"target\", help=\"Spreadsheet URL or spreadsheet token\")\n worksheet = parser.add_mutually_exclusive_group()\n worksheet.add_argument(\"--worksheet-id\")\n worksheet.add_argument(\"--worksheet-name\")\n parser.add_argument(\"--chart-type\", choices=sorted(SUPPORTED_CHART_TYPES), required=True)\n parser.add_argument(\"--data-range\", required=True)\n parser.add_argument(\"--header-range\")\n parser.add_argument(\"--data-direction\", choices=(\"column\", \"row\"), default=\"column\")\n parser.add_argument(\"--dim1-index\", type=int, default=1)\n parser.add_argument(\"--dim2-indexes\")\n parser.add_argument(\"--series-types\", type=_comma_separated_values)\n parser.add_argument(\"--series-y-axes\", type=_series_y_axes_argument)\n parser.add_argument(\"--data-labels\", default=\"none\")\n parser.add_argument(\"--aggregate-categories\", type=_boolean_argument, default=True)\n parser.add_argument(\"--legend-position\", default=\"bottom\")\n parser.add_argument(\"--title\", default=\"\")\n parser.add_argument(\"--timeout\", type=int, default=60)\n return parser.parse_args(argv)\n\n\ndef main() -> None:\n args = parse_args()\n locator = _locator(args.target)\n try:\n sheets: list[dict[str, Any]] = []\n if _needs_workbook_metadata(\n [args.data_range, args.header_range],\n worksheet_id=args.worksheet_id,\n worksheet_name=args.worksheet_name,\n ):\n workbook = envelope_data(\n _run_read(\n \"+workbook-info\",\n stage=\"workbook metadata\",\n **locator,\n timeout=args.timeout,\n )\n )\n sheets = extract_sheets(workbook)\n matrices = _read_ranges(\n locator,\n sheets,\n args.data_range,\n worksheet_id=args.worksheet_id,\n worksheet_name=args.worksheet_name,\n timeout=args.timeout,\n )\n headers = None\n if args.header_range:\n header_matrices = _read_ranges(\n locator,\n sheets,\n args.header_range,\n worksheet_id=args.worksheet_id,\n worksheet_name=args.worksheet_name,\n timeout=args.timeout,\n stage_prefix=\"header range\",\n )\n headers = _header_values(header_matrices, args.data_direction)\n profile = profile_matrix(\n _combine(matrices, args.data_direction),\n direction=args.data_direction,\n dim1_index=args.dim1_index,\n dim2_indexes=args.dim2_indexes,\n detached_headers=headers,\n )\n result = recommend_chart_size(\n chart_type=args.chart_type,\n categories=profile[\"categories\"],\n series_names=profile[\"series_names\"],\n data_labels=args.data_labels,\n legend_position=args.legend_position,\n title=args.title,\n values=profile[\"values\"],\n aggregate_categories=args.aggregate_categories,\n series_types=args.series_types,\n series_y_axes=args.series_y_axes,\n )\n result[\"data_profile\"] = {\n \"dim2_indexes\": profile[\"dim2_indexes\"],\n **result.pop(\"evidence\"),\n }\n except (LarkCliError, KeyError, TypeError, ValueError) as exc:\n emit_error(ACTION, str(exc))\n raise SystemExit(1) from exc\n emit_success(ACTION, result)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14284,"content_sha256":"66009455fa0b7de80ecf3551b7b885ccca14ff28fe634cc318830f145a00a82c"},{"filename":"scripts/lark_chart_size_rules.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Pure sizing heuristics shared by Lark chart helper scripts.\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nimport unicodedata\nfrom typing import Any\n\n\nMINIMUM_SIZES = {\n \"column\": (640, 400),\n \"line\": (640, 400),\n \"area\": (640, 400),\n \"bar\": (720, 420),\n \"combo\": (720, 420),\n \"pie\": (720, 440),\n}\nSUPPORTED_CHART_TYPES = {\n \"column\",\n \"bar\",\n \"line\",\n \"area\",\n \"pie\",\n \"scatter\",\n \"combo\",\n \"radar\",\n \"bubble\",\n \"waterfall\",\n \"pareto\",\n}\nDEFAULT_MINIMUM_SIZE = (640, 400)\nMAX_CHART_WIDTH = 1600\nMAX_CHART_HEIGHT = 720\nMAX_ASPECT_RATIO = 2.6\nCOMBO_SERIES_TYPES = {\"column\", \"line\", \"area\", \"scatter\"}\nCOMBO_SERIES_Y_AXES = {\"left\", \"right\"}\n\n\ndef display_units(value: Any) -> int:\n \"\"\"Estimate visible text width; CJK/full-width characters count double.\"\"\"\n lines = str(value if value is not None else \"\").splitlines() or [\"\"]\n return max(\n sum(2 if unicodedata.east_asian_width(char) in {\"W\", \"F\", \"A\"} else 1 for char in line)\n for line in lines\n )\n\n\ndef _round_up(value: float, step: int = 40) -> int:\n return int(math.ceil(value / step) * step)\n\n\ndef _round_down(value: float, step: int = 40) -> int:\n return int(math.floor(value / step) * step)\n\n\ndef _percentile(values: list[int], ratio: float) -> int:\n if not values:\n return 0\n ordered = sorted(values)\n return ordered[max(0, math.ceil(len(ordered) * ratio) - 1)]\n\n\ndef _has_clustered_small_slices(values: list[float]) -> bool:\n positive = [value for value in values if value > 0]\n total = sum(positive)\n if not total:\n return False\n shares = [value / total for value in positive]\n return max(shares, default=0) >= 0.75 and sum(share \u003c 0.05 for share in shares) >= 3\n\n\ndef minimum_chart_size(chart_type: str) -> dict[str, int]:\n width, height = MINIMUM_SIZES.get(str(chart_type).lower(), DEFAULT_MINIMUM_SIZE)\n return {\"width\": width, \"height\": height}\n\n\ndef estimate_legend_rows(items: list[str], width: int) -> int:\n if not items:\n return 0\n available = max(240, width - 80)\n used = 0\n rows = 1\n for item in items:\n item_width = min(320, 34 + display_units(item) * 7)\n if used and used + item_width > available:\n rows += 1\n used = 0\n used += item_width\n return rows\n\n\ndef effective_category_labels(\n categories: list[Any], *, aggregate_categories: bool = True\n) -> list[str]:\n labels = [str(value if value is not None else \"\") for value in categories]\n if aggregate_categories:\n return list(dict.fromkeys(labels))\n return labels\n\n\ndef effective_series_types(\n chart_type: str,\n series_count: int,\n series_types: list[str] | None = None,\n) -> list[str]:\n chart_type = str(chart_type).lower()\n if chart_type != \"combo\":\n if series_types:\n raise ValueError(\"series_types is only valid for combo charts\")\n return [chart_type] * series_count\n if series_types is None:\n return [\"column\", *([\"line\"] * max(0, series_count - 1))]\n normalized = [str(value).strip().lower() for value in series_types]\n if len(normalized) != series_count:\n raise ValueError(\"series_types length must match series_names\")\n invalid = [value for value in normalized if value not in COMBO_SERIES_TYPES]\n if invalid:\n raise ValueError(f\"unsupported combo series type: {invalid[0]}\")\n return normalized\n\n\ndef effective_series_y_axes(\n chart_type: str,\n series_count: int,\n series_y_axes: list[str] | None = None,\n) -> list[str]:\n chart_type = str(chart_type).lower()\n if chart_type != \"combo\":\n if series_y_axes:\n raise ValueError(\"series_y_axes is only valid for combo charts\")\n return [\"left\"] * series_count\n if series_y_axes is None:\n return [\"left\", *([\"right\"] * max(0, series_count - 1))]\n normalized = [str(value).strip().lower() for value in series_y_axes]\n if len(normalized) != series_count:\n raise ValueError(\"series_y_axes length must match series_names\")\n invalid = [value for value in normalized if value not in COMBO_SERIES_Y_AXES]\n if invalid:\n raise ValueError(f\"unsupported combo series Y axis: {invalid[0]}\")\n return normalized\n\n\ndef recommend_chart_size(\n *,\n chart_type: str,\n categories: list[Any],\n series_names: list[str],\n data_labels: str = \"none\",\n legend_position: str = \"bottom\",\n title: str = \"\",\n values: list[float] | None = None,\n aggregate_categories: bool = True,\n series_types: list[str] | None = None,\n series_y_axes: list[str] | None = None,\n) -> dict[str, Any]:\n chart_type = str(chart_type).lower()\n if chart_type not in SUPPORTED_CHART_TYPES:\n raise ValueError(f\"unsupported chart type: {chart_type}\")\n category_text = effective_category_labels(\n categories,\n aggregate_categories=aggregate_categories,\n )\n category_count = len(category_text)\n series_count = max(1, len(series_names))\n normalized_series_types = effective_series_types(\n chart_type,\n series_count,\n series_types,\n )\n normalized_series_y_axes = effective_series_y_axes(\n chart_type,\n series_count,\n series_y_axes,\n )\n column_series_count = sum(value == \"column\" for value in normalized_series_types)\n line_like_series_count = series_count - column_series_count\n label_units = [display_units(value) for value in category_text]\n max_units = max(label_units, default=0)\n p75_units = _percentile(label_units, 0.75)\n max_lines = max((len(value.splitlines()) for value in category_text), default=1)\n labels_enabled = str(data_labels or \"\").lower() not in {\"\", \"none\"}\n minimum = minimum_chart_size(chart_type)\n width = float(minimum[\"width\"])\n height = float(minimum[\"height\"])\n reasons: list[str] = []\n advice: list[str] = []\n\n if chart_type == \"pie\":\n label_reserve = max(150, min(360, max_units * 7 + 60))\n width = max(width, 420 + 2 * label_reserve)\n if labels_enabled:\n reasons.append(\"outside_slice_labels\")\n if values and _has_clustered_small_slices(values):\n height += 40\n reasons.append(\"clustered_small_slices\")\n if category_count > 8:\n advice.append(\"prefer_bar_or_top_n\")\n size_alone_is_insufficient = category_count > 12\n elif chart_type == \"bar\":\n width = max(width, 420 + max_units * 7)\n height = max(height, 190 + category_count * 36)\n height_limited = _round_up(height) > MAX_CHART_HEIGHT\n size_alone_is_insufficient = category_count > 24\n if height_limited:\n reasons.append(\"maximum_height_limited\")\n if size_alone_is_insufficient:\n advice.extend([\"use_top_n\", \"split_chart\"])\n else:\n reserve = (\n 230\n if chart_type == \"combo\" and \"right\" in normalized_series_y_axes\n else 170\n )\n line_dominant_combo = chart_type == \"combo\" and column_series_count == 0\n base_slot = 44 if chart_type in {\"line\", \"area\"} or line_dominant_combo else 52\n text_slot = 20 + p75_units * 7 * 0.72\n slot = max(base_slot, min(180, text_slot))\n if column_series_count \u003c= 1 and category_count >= 10:\n # With many categories, Sheet rotates X-axis labels. Reserving each\n # label's full horizontal text width makes single-series charts\n # disproportionately wide; density checks below still expand when\n # data labels would actually collide.\n slot = min(slot, 68)\n if column_series_count > 1:\n slot = max(slot, 44 + 12 * min(column_series_count - 1, 4))\n if chart_type == \"combo\" and line_like_series_count > 1:\n slot += min(12, 4 * (line_like_series_count - 1))\n if labels_enabled:\n slot += min(24, 4 * series_count)\n width = max(width, reserve + category_count * slot)\n if p75_units > 12:\n height += 40\n reasons.append(\"long_category_labels\")\n if max_lines > 1:\n height += min(120, 40 * (max_lines - 1))\n reasons.append(\"multiline_category_labels\")\n size_alone_is_insufficient = (\n category_count > 20\n and (p75_units > 12 or series_count > 3 or labels_enabled)\n )\n if size_alone_is_insufficient:\n advice.extend([\"prefer_bar_or_top_n\", \"split_chart\"])\n\n width = min(MAX_CHART_WIDTH, _round_up(width))\n aspect_width_limit = max(minimum[\"width\"], _round_down(height * MAX_ASPECT_RATIO))\n if width > aspect_width_limit:\n width = aspect_width_limit\n reasons.append(\"aspect_ratio_limited\")\n legend_items = category_text if chart_type == \"pie\" else series_names\n legend_rows = 0\n if str(legend_position).lower() != \"hidden\":\n legend_rows = estimate_legend_rows(legend_items, width)\n if legend_rows > 1:\n height += (legend_rows - 1) * 32\n reasons.append(\"multi_row_legend\")\n\n if title:\n reasons.append(\"chart_title\")\n\n height = min(MAX_CHART_HEIGHT, _round_up(height))\n if size_alone_is_insufficient:\n if chart_type == \"pie\":\n height = max(height, 520)\n elif chart_type != \"bar\":\n width = max(width, 1200)\n height = max(height, 520)\n\n return {\n \"minimum_size\": minimum,\n \"recommended_size\": {\"width\": width, \"height\": height},\n \"create_flags\": {\"width\": width, \"height\": height},\n \"evidence\": {\n \"chart_type\": chart_type,\n \"series_count\": series_count,\n \"series_types\": normalized_series_types,\n \"series_y_axes\": normalized_series_y_axes,\n \"column_series_count\": column_series_count,\n \"max_category_display_units\": max_units,\n \"p75_category_display_units\": p75_units,\n \"max_category_line_count\": max_lines,\n \"legend_rows\": legend_rows,\n \"data_labels\": data_labels,\n \"aggregate_categories\": aggregate_categories,\n \"recommended_aspect_ratio\": round(width / height, 2),\n },\n \"reasons\": list(dict.fromkeys(reasons)),\n \"layout_advice\": list(dict.fromkeys(advice)),\n \"size_alone_is_insufficient\": size_alone_is_insufficient,\n }\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10473,"content_sha256":"96ac04ce5ae918103f3ebecd5edd57690bd5c9257e6c823f1e2322135460d0c8"},{"filename":"scripts/lark_detect_subtables.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Detect occupied subtable regions in a Lark sheet.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport csv\nimport io\nimport re\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom typing import Any\n\nfrom lark_sheet_range import RangeBounds, col_to_index, format_range, index_to_col, parse_range, range_union\nfrom lark_sheet_read_cli import (\n LarkCliError,\n add_spreadsheet_args,\n emit_error,\n emit_success,\n envelope_data,\n resolve_target_sheets,\n run_sheets,\n sheet_identifier,\n sheet_locator,\n sheet_title,\n)\n\nACTION = \"detect_subtables\"\nROW_PREFIX_RE = re.compile(r\"^\\[row=(\\d+)\\]\\s?(.*)$\")\nMAX_EXTERNAL_MERGE_ANCHOR_CHECKS = 10\n\n\ndef _inside_quoted_field(lines: list[str]) -> bool:\n \"\"\"True when the accumulated record has an unterminated quoted field.\n\n RFC 4180 escapes a literal quote by doubling it, so both halves of a `\"\"`\n pair count and parity still tracks whether a field is left open. A record\n that is still open must swallow the next physical line verbatim — even one\n that looks like a `[row=N]` prefix, because inside quotes that text is\n ordinary cell content, not a new record.\n \"\"\"\n return sum(line.count('\"') for line in lines) % 2 == 1\n\n\n@dataclass\nclass CsvGrid:\n row_numbers: list[int]\n col_letters: list[str]\n values: list[list[str]]\n row_numbers_inferred: bool = False\n\n\n@dataclass\nclass Component:\n bounds: RangeBounds\n occupied_count: int\n\n\ndef parse_annotated_csv(\n text: str,\n col_indices: list[str] | None = None,\n row_indices: list[int] | None = None,\n source_range: str | None = None,\n) -> CsvGrid:\n row_numbers: list[int] = []\n values: list[list[str]] = []\n max_cols = 0\n lines = (text or \"\").splitlines()\n row_numbers_inferred = False\n has_authoritative_rows = isinstance(row_indices, list) and len(row_indices) > 0\n\n first_meaningful = next((line for line in lines if line.strip()), \"\")\n if ROW_PREFIX_RE.match(first_meaningful):\n records = []\n current_lines: list[str] | None = None\n current_row_number: int | None = None\n for line in lines:\n match = ROW_PREFIX_RE.match(line)\n if match and current_lines is not None and _inside_quoted_field(current_lines):\n # Prefix-looking text inside an open quoted field is content.\n current_lines.append(line)\n continue\n if match:\n if current_lines is not None and current_row_number is not None:\n records.append(\"\\n\".join(current_lines))\n row_numbers.append(current_row_number)\n current_row_number = int(match.group(1))\n current_lines = [match.group(2)]\n elif current_lines is not None:\n current_lines.append(line)\n if current_lines is not None and current_row_number is not None:\n records.append(\"\\n\".join(current_lines))\n row_numbers.append(current_row_number)\n\n # Cross-check against the row numbers the server itself reported.\n # _inside_quoted_field decides whether a \"[row=N]\" line starts a new\n # record or is content inside an open quoted field; when the payload's\n # quoting is malformed (a lone unescaped quote in a cell), that call\n # goes the wrong way and every following line is swallowed into the\n # previous cell — the rows simply vanish, and everything downstream\n # (data_range, last data row, column profiles) is quietly computed from\n # a short grid. row_indices is authoritative and already in hand, so\n # refuse rather than profile a grid that does not match it.\n if has_authoritative_rows and row_numbers != [int(r) for r in row_indices]:\n raise ValueError(\n \"annotated_csv did not parse into the rows the server reported \"\n f\"(parsed {len(row_numbers)} rows {row_numbers[:5]}…, expected \"\n f\"{len(row_indices)} rows {list(row_indices)[:5]}…) — most likely \"\n \"an unbalanced quote in a cell. Re-read a narrower --range, or use \"\n \"+cells-get for this region instead of the CSV path.\"\n )\n\n for record in records:\n parsed = next(csv.reader([record]))\n values.append(parsed)\n max_cols = max(max_cols, len(parsed))\n else:\n reader = csv.reader(io.StringIO(text or \"\"))\n fallback_start = 1\n if source_range:\n fallback_start = parse_range(\n source_range,\n max_row=1_048_576,\n max_col=18_278,\n ).start_row\n for offset, row in enumerate(reader):\n row_num = None\n if has_authoritative_rows and offset \u003c len(row_indices):\n try:\n row_num = int(row_indices[offset])\n except (TypeError, ValueError):\n # Non-numeric row index: leave row_num as None so the\n # inferred fallback numbering below takes over.\n pass\n if row_num is None:\n row_numbers_inferred = True\n row_numbers.append(row_num if row_num is not None else fallback_start + offset)\n values.append(row)\n max_cols = max(max_cols, len(row))\n\n if col_indices:\n col_letters = [str(col) for col in col_indices[:max_cols]]\n while len(col_letters) \u003c max_cols:\n next_col = col_to_index(col_letters[-1]) + 1 if col_letters else len(col_letters) + 1\n col_letters.append(index_to_col(next_col))\n else:\n col_letters = [index_to_col(i) for i in range(1, max_cols + 1)]\n\n for row in values:\n row.extend([\"\"] * (len(col_letters) - len(row)))\n inferred = bool(values) and not ROW_PREFIX_RE.match(first_meaningful) and (\n row_numbers_inferred or not has_authoritative_rows\n )\n return CsvGrid(\n row_numbers=row_numbers,\n col_letters=col_letters,\n values=values,\n row_numbers_inferred=inferred,\n )\n\n\ndef _merged_ranges(layout: dict[str, Any]) -> list[str]:\n merges = layout.get(\"merged_cells\") or layout.get(\"merges\") or []\n result = []\n for item in merges:\n if isinstance(item, str):\n result.append(item)\n elif isinstance(item, dict):\n value = item.get(\"range\") or item.get(\"a1_range\") or item.get(\"range_ref\")\n if isinstance(value, str):\n result.append(value)\n return result\n\n\ndef _scan_bounds(grid: CsvGrid) -> RangeBounds | None:\n col_numbers = [col_to_index(col) for col in grid.col_letters]\n if grid.row_numbers and grid.col_letters:\n return RangeBounds(\n min(grid.row_numbers),\n min(col_numbers),\n max(grid.row_numbers),\n max(col_numbers),\n )\n return None\n\n\ndef _external_merge_anchors(grid: CsvGrid, layout: dict[str, Any]) -> dict[str, str]:\n scan_bounds = _scan_bounds(grid)\n if scan_bounds is None:\n return {}\n result = {}\n for merge_ref in _merged_ranges(layout):\n try:\n bounds = parse_range(merge_ref)\n except ValueError:\n continue\n intersects = not (\n bounds.end_row \u003c scan_bounds.start_row\n or bounds.start_row > scan_bounds.end_row\n or bounds.end_col \u003c scan_bounds.start_col\n or bounds.start_col > scan_bounds.end_col\n )\n anchor_in_scan = (\n scan_bounds.start_row \u003c= bounds.start_row \u003c= scan_bounds.end_row\n and scan_bounds.start_col \u003c= bounds.start_col \u003c= scan_bounds.end_col\n )\n if intersects and not anchor_in_scan:\n result[merge_ref] = format_range(\n bounds.start_row, bounds.start_col, bounds.start_row, bounds.start_col\n )\n return result\n\n\ndef _has_value(grid: CsvGrid) -> bool:\n return any(value.strip() for row in grid.values for value in row)\n\n\ndef build_occupancy(\n grid: CsvGrid,\n layout: dict[str, Any],\n *,\n confirmed_external_merges: set[str] | None = None,\n) -> set[tuple[int, int]]:\n col_numbers = [col_to_index(col) for col in grid.col_letters]\n occupied: set[tuple[int, int]] = set()\n for row_idx, row_num in enumerate(grid.row_numbers):\n for col_idx, value in enumerate(grid.values[row_idx]):\n if value.strip():\n occupied.add((row_num, col_numbers[col_idx]))\n\n scan_bounds = _scan_bounds(grid)\n\n for merge_ref in _merged_ranges(layout):\n try:\n bounds = parse_range(merge_ref)\n except ValueError:\n continue\n if scan_bounds is None:\n continue\n anchor_in_scan = (\n scan_bounds.start_row \u003c= bounds.start_row \u003c= scan_bounds.end_row\n and scan_bounds.start_col \u003c= bounds.start_col \u003c= scan_bounds.end_col\n )\n if anchor_in_scan and (bounds.start_row, bounds.start_col) not in occupied:\n continue\n if not anchor_in_scan and merge_ref not in (confirmed_external_merges or set()):\n continue\n sr = max(bounds.start_row, scan_bounds.start_row)\n er = min(bounds.end_row, scan_bounds.end_row)\n sc = max(bounds.start_col, scan_bounds.start_col)\n ec = min(bounds.end_col, scan_bounds.end_col)\n if sr > er or sc > ec:\n continue\n for row in range(sr, er + 1):\n for col in range(sc, ec + 1):\n occupied.add((row, col))\n return occupied\n\n\ndef _raw_components(\n occupied: set[tuple[int, int]],\n *,\n adjacent_rows: dict[int, set[int]] | None = None,\n adjacent_cols: dict[int, set[int]] | None = None,\n) -> list[Component]:\n remaining = set(occupied)\n components = []\n while remaining:\n start = remaining.pop()\n queue = deque([start])\n cells = [start]\n while queue:\n row, col = queue.popleft()\n vertical_rows = adjacent_rows.get(row, set()) if adjacent_rows else {row - 1, row + 1}\n neighbors = [(neighbor_row, col) for neighbor_row in vertical_rows]\n # Columns get the same treatment as rows: with --skip-hidden the\n # returned columns can be non-consecutive (A, C when B is hidden),\n # so col±1 would split visually adjacent data into two components.\n horizontal_cols = adjacent_cols.get(col, set()) if adjacent_cols else {col - 1, col + 1}\n neighbors.extend((row, neighbor_col) for neighbor_col in horizontal_cols)\n for neighbor in neighbors:\n if neighbor in remaining:\n remaining.remove(neighbor)\n queue.append(neighbor)\n cells.append(neighbor)\n rows = [cell[0] for cell in cells]\n cols = [cell[1] for cell in cells]\n components.append(\n Component(\n RangeBounds(min(rows), min(cols), max(rows), max(cols)),\n len(cells),\n )\n )\n return components\n\n\ndef _box_gap_mergeable(a: RangeBounds, b: RangeBounds, gap_rows: int, gap_cols: int) -> bool:\n cols_overlap = not (a.end_col \u003c b.start_col or b.end_col \u003c a.start_col)\n rows_overlap = not (a.end_row \u003c b.start_row or b.end_row \u003c a.start_row)\n vertical_gap = max(b.start_row - a.end_row - 1, a.start_row - b.end_row - 1, 0)\n horizontal_gap = max(b.start_col - a.end_col - 1, a.start_col - b.end_col - 1, 0)\n return (cols_overlap and vertical_gap \u003c= gap_rows) or (\n rows_overlap and horizontal_gap \u003c= gap_cols\n )\n\n\ndef merge_components(\n components: list[Component], *, gap_rows: int = 1, gap_cols: int = 0\n) -> list[Component]:\n merged = components[:]\n changed = True\n while changed:\n changed = False\n next_components: list[Component] = []\n used = [False] * len(merged)\n for i, comp in enumerate(merged):\n if used[i]:\n continue\n current = Component(comp.bounds, comp.occupied_count)\n used[i] = True\n for j in range(i + 1, len(merged)):\n if used[j]:\n continue\n other = merged[j]\n if _box_gap_mergeable(current.bounds, other.bounds, gap_rows, gap_cols):\n current = Component(\n range_union(current.bounds, other.bounds),\n current.occupied_count + other.occupied_count,\n )\n used[j] = True\n changed = True\n next_components.append(current)\n merged = next_components\n return merged\n\n\ndef _row_values(grid: CsvGrid, bounds: RangeBounds, row_num: int) -> list[str]:\n if row_num not in grid.row_numbers:\n return []\n row = grid.values[grid.row_numbers.index(row_num)]\n values = []\n for col_idx, col_letter in enumerate(grid.col_letters):\n col_num = col_to_index(col_letter)\n if bounds.start_col \u003c= col_num \u003c= bounds.end_col:\n values.append(row[col_idx] if col_idx \u003c len(row) else \"\")\n return values\n\n\ndef header_candidates(grid: CsvGrid, bounds: RangeBounds) -> list[int]:\n candidates = []\n for row in range(bounds.start_row, min(bounds.end_row, bounds.start_row + 4) + 1):\n values = _row_values(grid, bounds, row)\n non_empty = [value for value in values if value.strip()]\n if len(non_empty) >= max(1, min(2, bounds.col_count)):\n candidates.append(row)\n return candidates\n\n\ndef kind_guess(bounds: RangeBounds, density: float) -> str:\n if bounds.row_count >= 3 and bounds.col_count >= 2 and density >= 0.25:\n return \"data_table\"\n if bounds.row_count \u003c= 2 or bounds.col_count \u003c= 1:\n return \"note_or_label\"\n if density \u003c 0.25:\n return \"sparse_block\"\n return \"summary_block\"\n\n\ndef summarize_components(grid: CsvGrid, components: list[Component], min_cells: int) -> list[dict[str, Any]]:\n result = []\n for idx, comp in enumerate(\n sorted(components, key=lambda item: (item.bounds.start_row, item.bounds.start_col)),\n start=1,\n ):\n if comp.occupied_count \u003c min_cells:\n continue\n area = comp.bounds.row_count * comp.bounds.col_count\n density = comp.occupied_count / area if area else 0\n samples = []\n for row in range(comp.bounds.start_row, min(comp.bounds.end_row, comp.bounds.start_row + 2) + 1):\n samples.append(_row_values(grid, comp.bounds, row))\n result.append(\n {\n \"id\": f\"T{idx}\",\n \"range\": format_range(\n comp.bounds.start_row,\n comp.bounds.start_col,\n comp.bounds.end_row,\n comp.bounds.end_col,\n ),\n \"rows\": comp.bounds.row_count,\n \"cols\": comp.bounds.col_count,\n \"occupied_cells\": comp.occupied_count,\n \"density\": round(density, 4),\n \"header_candidates\": header_candidates(grid, comp.bounds),\n \"kind_guess\": kind_guess(comp.bounds, density),\n \"sample\": samples,\n }\n )\n return result\n\n\ndef detect_subtables(args) -> tuple[dict[str, Any], list[str]]:\n warnings: list[str] = []\n workbook = envelope_data(\n run_sheets(\n \"+workbook-info\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n timeout=args.timeout,\n )\n )\n sheet = resolve_target_sheets(\n workbook,\n sheet_id=args.sheet_id,\n sheet_name=args.sheet_name,\n require_one=True,\n )[0]\n sid = sheet_identifier(sheet)\n title = sheet_title(sheet)\n locator = sheet_locator(sheet)\n\n col_count = min(int(sheet.get(\"column_count\") or args.max_scan_cols), args.max_scan_cols)\n row_count = min(int(sheet.get(\"row_count\") or args.max_scan_rows), args.max_scan_rows)\n scan_range = args.range or f\"A1:{index_to_col(max(1, col_count))}{max(1, row_count)}\"\n if not args.range:\n if int(sheet.get(\"column_count\") or 0) > args.max_scan_cols:\n warnings.append(f\"scan clipped to first {args.max_scan_cols} columns\")\n if int(sheet.get(\"row_count\") or 0) > args.max_scan_rows:\n warnings.append(f\"scan clipped to first {args.max_scan_rows} rows\")\n\n layout = envelope_data(\n run_sheets(\n \"+sheet-info\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n **locator,\n flags={\"include\": \"merges,hidden_rows,hidden_cols\"},\n timeout=args.timeout,\n )\n )\n csv_data = envelope_data(\n run_sheets(\n \"+csv-get\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n **locator,\n flags={\n \"range\": scan_range,\n \"max_chars\": args.max_chars,\n \"skip_hidden\": True if args.skip_hidden else None,\n },\n timeout=args.timeout,\n )\n )\n actual_range = str(csv_data.get(\"actual_range\") or scan_range)\n if csv_data.get(\"has_more\"):\n raise LarkCliError(\n f\"+csv-get truncated the scan range at {actual_range}; narrow --range before detecting subtables\"\n )\n\n grid = parse_annotated_csv(\n csv_data.get(\"annotated_csv\", \"\"),\n csv_data.get(\"col_indices\"),\n csv_data.get(\"row_indices\"),\n actual_range,\n )\n if grid.row_numbers_inferred:\n warnings.append(\"CSV row numbers were inferred from the requested range\")\n hidden_rows_raw = layout.get(\"hidden_rows\") or []\n hidden_row_indexes = {\n int(value) + 1\n for value in hidden_rows_raw\n if isinstance(value, (int, str)) and str(value).isdigit()\n }\n hidden_columns_raw = layout.get(\"hidden_cols\") or layout.get(\"hidden_columns\") or []\n hidden_col_letters = set()\n for value in hidden_columns_raw if isinstance(hidden_columns_raw, list) else []:\n if isinstance(value, str) and value.isalpha():\n hidden_col_letters.add(value.upper())\n elif isinstance(value, (int, str)) and str(value).isdigit():\n hidden_col_letters.add(index_to_col(int(value) + 1))\n hidden_rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)\n hidden_columns = [col for col in grid.col_letters if col.upper() in hidden_col_letters]\n if hidden_rows or hidden_columns:\n warnings.append(\"scan includes hidden rows or columns; pass --skip-hidden to exclude them\")\n confirmed_external_merges = set()\n external_merge_anchors = list(_external_merge_anchors(grid, layout).items())\n if len(external_merge_anchors) > MAX_EXTERNAL_MERGE_ANCHOR_CHECKS:\n warnings.append(\n f\"skipped confirmation for {len(external_merge_anchors) - MAX_EXTERNAL_MERGE_ANCHOR_CHECKS} \"\n f\"external merge anchors (limit: {MAX_EXTERNAL_MERGE_ANCHOR_CHECKS})\"\n )\n for merge_ref, anchor in external_merge_anchors[:MAX_EXTERNAL_MERGE_ANCHOR_CHECKS]:\n try:\n anchor_data = envelope_data(\n run_sheets(\n \"+csv-get\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n **locator,\n flags={\n \"range\": anchor,\n \"max_chars\": 1024,\n \"skip_hidden\": True if args.skip_hidden else None,\n },\n timeout=args.timeout,\n )\n )\n anchor_grid = parse_annotated_csv(\n anchor_data.get(\"annotated_csv\", \"\"),\n anchor_data.get(\"col_indices\"),\n anchor_data.get(\"row_indices\"),\n anchor,\n )\n if _has_value(anchor_grid):\n confirmed_external_merges.add(merge_ref)\n except (LarkCliError, ValueError) as exc:\n warnings.append(f\"could not confirm merge anchor {anchor}: {exc}\")\n occupied = build_occupancy(\n grid,\n layout,\n confirmed_external_merges=confirmed_external_merges,\n )\n adjacent_rows = None\n adjacent_cols = None\n if args.skip_hidden:\n adjacent_rows = {}\n for previous, current in zip(grid.row_numbers, grid.row_numbers[1:]):\n adjacent_rows.setdefault(previous, set()).add(current)\n adjacent_rows.setdefault(current, set()).add(previous)\n col_numbers = [col_to_index(col) for col in grid.col_letters]\n adjacent_cols = {}\n for previous, current in zip(col_numbers, col_numbers[1:]):\n adjacent_cols.setdefault(previous, set()).add(current)\n adjacent_cols.setdefault(current, set()).add(previous)\n raw_components = _raw_components(\n occupied,\n adjacent_rows=adjacent_rows,\n adjacent_cols=adjacent_cols,\n )\n if len(raw_components) > args.max_merge_components:\n warnings.append(\n f\"skipped gap-based component merging for {len(raw_components)} components \"\n f\"(limit: {args.max_merge_components})\"\n )\n components = raw_components\n else:\n components = merge_components(\n raw_components,\n gap_rows=args.gap_rows,\n gap_cols=args.gap_cols,\n )\n subtables = summarize_components(grid, components, args.min_cells)\n return {\n \"sheet_id\": sid,\n \"sheet\": title,\n \"scan_range\": scan_range,\n \"actual_range\": actual_range,\n \"visibility\": {\n \"skip_hidden\": args.skip_hidden,\n \"hidden_rows_in_range\": hidden_rows,\n \"hidden_columns_in_range\": hidden_columns,\n },\n \"subtables\": subtables,\n }, warnings\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=__doc__)\n add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)\n parser.add_argument(\"--range\")\n parser.add_argument(\"--max-scan-rows\", type=int, default=5000)\n parser.add_argument(\"--max-scan-cols\", type=int, default=200)\n parser.add_argument(\"--gap-rows\", type=int, default=1)\n parser.add_argument(\"--gap-cols\", type=int, default=0)\n parser.add_argument(\"--min-cells\", type=int, default=2)\n parser.add_argument(\"--max-merge-components\", type=int, default=2000)\n parser.add_argument(\"--max-chars\", type=int, default=25000)\n parser.add_argument(\"--skip-hidden\", action=\"store_true\")\n parser.add_argument(\"--timeout\", type=int, default=60)\n args = parser.parse_args()\n try:\n data, warnings = detect_subtables(args)\n except (LarkCliError, ValueError, TypeError) as exc:\n emit_error(ACTION, str(exc))\n emit_success(ACTION, data, warnings)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22988,"content_sha256":"ac0016c23cc051f1fc2fcf4cf5e8ed272510ee1e7f9e64d548083188383db203"},{"filename":"scripts/lark_inspect_workbook.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Inspect a Lark spreadsheet and emit a compact workbook profile.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nfrom typing import Any\n\nfrom lark_sheet_range import index_to_col\nfrom lark_sheet_read_cli import (\n LarkCliError,\n add_spreadsheet_args,\n emit_error,\n emit_success,\n envelope_data,\n is_grid_sheet,\n resolve_target_sheets,\n run_sheets,\n sheet_identifier,\n sheet_locator,\n sheet_resource_type,\n sheet_title,\n visible_grid_selection,\n)\n\nACTION = \"inspect_workbook\"\nLAYOUT_INCLUDE = \"merges,row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen\"\n\n\ndef _sheet_summary(sheet: dict[str, Any]) -> dict[str, Any]:\n return {\n \"sheet_id\": sheet_identifier(sheet),\n \"title\": sheet_title(sheet),\n \"index\": sheet.get(\"index\"),\n \"resource_type\": sheet_resource_type(sheet),\n \"row_count\": sheet.get(\"row_count\"),\n \"column_count\": sheet.get(\"column_count\"),\n \"is_hidden\": sheet.get(\"is_hidden\"),\n \"merged_cells_count\": sheet.get(\"merged_cells_count\"),\n \"chart_count\": sheet.get(\"chart_count\"),\n \"pivot_table_count\": sheet.get(\"pivot_table_count\"),\n \"float_image_count\": sheet.get(\"float_image_count\"),\n }\n\n\ndef _list_count(value: Any) -> int:\n return len(value) if isinstance(value, list) else 0\n\n\ndef _layout_summary(layout: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Retain layout signals without serializing unbounded per-cell metadata.\"\"\"\n merges = layout.get(\"merged_cells\") or layout.get(\"merges\") or []\n groups = layout.get(\"groups\") if isinstance(layout.get(\"groups\"), dict) else {}\n row_groups = layout.get(\"row_groups\") or groups.get(\"rows\", [])\n col_groups = layout.get(\"column_groups\") or groups.get(\"columns\", [])\n return {\n \"merge_count\": _list_count(merges),\n \"row_heights_count\": _list_count(layout.get(\"row_heights\")),\n \"column_widths_count\": _list_count(layout.get(\"col_widths\")),\n \"hidden_rows_count\": _list_count(layout.get(\"hidden_rows\")),\n \"hidden_columns_count\": _list_count(\n layout.get(\"hidden_cols\") or layout.get(\"hidden_columns\")\n ),\n \"row_groups_count\": _list_count(row_groups),\n \"column_groups_count\": _list_count(col_groups),\n \"frozen\": layout.get(\"frozen\"),\n }\n\n\ndef inspect_workbook(args) -> tuple[dict[str, Any], list[str]]:\n warnings: list[str] = []\n workbook = envelope_data(\n run_sheets(\n \"+workbook-info\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n timeout=args.timeout,\n )\n )\n # An explicit selector must resolve: without require_one a typo'd\n # --sheet-id/--sheet-name silently yields sheet_count 0, which reads as\n # \"empty workbook\" instead of a locator error.\n selection = visible_grid_selection(workbook, sheet_id=args.sheet_id, sheet_name=args.sheet_name)\n all_sheets = resolve_target_sheets(workbook)\n target_sheets = resolve_target_sheets(\n workbook,\n sheet_id=args.sheet_id,\n sheet_name=args.sheet_name,\n require_one=bool(args.sheet_id or args.sheet_name),\n )\n preview_targets = target_sheets\n if not args.sheet_id and not args.sheet_name:\n # is_hidden 缺失不等于隐藏:预览只是只读预检,除显式 is_hidden=true 外一律纳入,\n # 否则旧 payload 会静默退化成「只有 summary、零预览」。写入侧的保守可见性判定\n # 仍由 selection 负责(visibility_unknown 在那里照样排除)。\n preview_targets = [sheet for sheet in all_sheets if is_grid_sheet(sheet) and sheet.get(\"is_hidden\") is not True]\n if args.max_sheets \u003c 1:\n raise LarkCliError(\"--max-sheets must be at least 1\")\n inspect_count = min(len(preview_targets), args.max_sheets)\n if inspect_count \u003c len(preview_targets):\n warnings.append(\n f\"layout and preview skipped for {len(preview_targets) - inspect_count} visible grid sheets; \"\n \"pass --sheet-id or --sheet-name to inspect one\"\n )\n if not preview_targets and all_sheets:\n if any(is_grid_sheet(sheet) for sheet in all_sheets):\n warnings.append(\n \"no previewable grid sheet: every grid sheet is explicitly hidden; \"\n \"pass --sheet-id or --sheet-name to inspect one anyway\"\n )\n else:\n # 非网格子表在 visible_grid_selection 里会被显式拒绝,别提示「带 selector 重跑」\n # ——照做必然拿到 \"is not a grid sheet\",恢复动作等于死路。\n warnings.append(\n \"no grid sheet in this workbook: every sheet is non-grid; \"\n \"read it through the matching product API instead of the grid read/write path\"\n )\n # 按对象身份而非 sheet_id 圈定预览集合:payload 缺 sheet_id 时 sheet_identifier 全为\n # 空串,用 id 集合会把所有子表折叠成同一个 key,--max-sheets 直接失效(照样逐表发\n # +sheet-info / +csv-get)。all_sheets 与 preview_targets 取自同一份 workbook,元素是\n # 同一批 dict 实例,id() 比较安全。\n preview_marks = {id(sheet) for sheet in preview_targets[:inspect_count]}\n\n profiles = []\n for sheet in all_sheets:\n sid = sheet_identifier(sheet)\n title = sheet_title(sheet)\n profile = _sheet_summary(sheet)\n if id(sheet) not in preview_marks:\n profiles.append(profile)\n continue\n locator = sheet_locator(sheet)\n col_count = int(sheet.get(\"column_count\") or args.max_preview_cols)\n preview_cols = min(col_count, args.max_preview_cols)\n if col_count > args.max_preview_cols:\n warnings.append(\n f\"{title or sid}: preview clipped to first {args.max_preview_cols} columns\"\n )\n end_col = index_to_col(max(1, preview_cols))\n preview_range = f\"A1:{end_col}{args.preview_rows}\"\n\n # Per-sheet, not fail-the-run: this is the first-step pre-flight, and\n # one unreadable sheet (odd type, transient error, a locator that does\n # not resolve) must not throw away the summaries already collected for\n # every other sheet. The basic summary comes from +workbook-info and is\n # already in hand, so a failure here degrades detail, not correctness —\n # same call the sibling profile_table downgrades to a warning.\n try:\n layout = envelope_data(\n run_sheets(\n \"+sheet-info\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n **locator,\n flags={\"include\": LAYOUT_INCLUDE},\n timeout=args.timeout,\n )\n )\n preview = envelope_data(\n run_sheets(\n \"+csv-get\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n **locator,\n flags={\"range\": preview_range, \"max_chars\": args.max_chars},\n timeout=args.timeout,\n )\n )\n except LarkCliError as exc:\n warnings.append(\n f\"{title or sid}: layout/preview unavailable ({exc}); \"\n \"re-read this sheet on its own with --sheet-name, or use +sheet-info / +csv-get directly\"\n )\n profiles.append(profile)\n continue\n if preview.get(\"has_more\"):\n warnings.append(f\"{title or sid}: preview range {preview_range} was truncated\")\n\n profiles.append(\n {\n **profile,\n \"layout\": _layout_summary(layout),\n \"preview\": {\n \"range\": preview_range,\n \"current_region\": preview.get(\"current_region\"),\n \"row_indices\": preview.get(\"row_indices\"),\n \"col_indices\": preview.get(\"col_indices\"),\n \"annotated_csv\": preview.get(\"annotated_csv\"),\n \"has_more\": preview.get(\"has_more\"),\n },\n }\n )\n\n return {\"sheet_count\": len(all_sheets), \"sheets\": profiles, \"selection\": selection}, warnings\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=__doc__)\n add_spreadsheet_args(parser, require_sheet=False, allow_sheet=True)\n parser.add_argument(\"--preview-rows\", type=int, default=15)\n parser.add_argument(\"--max-preview-cols\", type=int, default=100)\n parser.add_argument(\"--max-chars\", type=int, default=8000)\n parser.add_argument(\"--max-sheets\", type=int, default=3)\n parser.add_argument(\"--timeout\", type=int, default=60)\n args = parser.parse_args()\n try:\n data, warnings = inspect_workbook(args)\n except (LarkCliError, ValueError, TypeError) as exc:\n emit_error(ACTION, str(exc))\n emit_success(ACTION, data, warnings)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9158,"content_sha256":"dd8ee6f9c0284a7f7f54ff96f11cc044f5e319e16c7e2b6466e78d9f372fbeee"},{"filename":"scripts/lark_profile_table.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Profile a candidate table range in a Lark sheet.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport re\nfrom typing import Any\n\nfrom lark_sheet_range import col_to_index, format_range, index_to_col, parse_range\nfrom lark_sheet_read_cli import (\n LarkCliError,\n add_spreadsheet_args,\n emit_error,\n emit_success,\n envelope_data,\n run_sheets,\n)\nfrom lark_detect_subtables import CsvGrid, parse_annotated_csv\n\nACTION = \"profile_table\"\nERROR_VALUES = (\"#VALUE!\", \"#DIV/0!\", \"#REF!\", \"#NAME?\", \"#NULL!\", \"#NUM!\", \"#N/A\")\nTOTAL_KEYWORDS = (\"合计\", \"总计\", \"小计\", \"汇总\", \"累计\")\nTOTAL_EN_RE = re.compile(r\"\\b(?:grand total|subtotal|total)\\b\", re.IGNORECASE)\nSIGNATURE_KEYWORDS = (\"编制人\", \"审核人\", \"审批人\", \"负责人\", \"经理\", \"签名\")\n\n\ndef _is_number(value: str) -> bool:\n text = value.strip().replace(\",\", \"\")\n if text.endswith(\"%\"):\n text = text[:-1]\n try:\n float(text)\n return True\n except ValueError:\n return False\n\n\ndef _is_date_like(value: str) -> bool:\n text = value.strip()\n if re.match(r\"^\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:\\s+\\d{1,2}:\\d{2}(:\\d{2})?)?$\", text):\n return True\n if re.match(r\"^\\d{8}$\", text):\n year = int(text[:4])\n month = int(text[4:6])\n day = int(text[6:8])\n return 1900 \u003c= year \u003c= 2100 and 1 \u003c= month \u003c= 12 and 1 \u003c= day \u003c= 31\n if re.match(r\"^\\d{6}$\", text):\n year = int(text[:4])\n month = int(text[4:6])\n return 1900 \u003c= year \u003c= 2100 and 1 \u003c= month \u003c= 12\n return False\n\n\ndef _classify_value(value: str) -> str:\n text = value.strip()\n if not text:\n return \"empty\"\n if any(err in text for err in ERROR_VALUES):\n return \"error\"\n if re.match(r\"^0\\d+$\", text) or re.match(r\"^\\d{11,}$\", text):\n return \"string_id\"\n if _is_date_like(text):\n return \"date\"\n if _is_number(text):\n return \"number\"\n if text.lower() in {\"true\", \"false\", \"yes\", \"no\"}:\n return \"bool\"\n return \"string\"\n\n\ndef _row_values(grid: CsvGrid, row_index: int) -> list[str]:\n return grid.values[row_index]\n\n\ndef _header_score(row: list[str]) -> tuple[int, int]:\n non_empty = [value for value in row if value.strip()]\n if not non_empty:\n return 0, 0\n strings = sum(1 for value in non_empty if _classify_value(value) in {\"string\", \"string_id\"})\n data_like = sum(\n 1 for value in non_empty if _classify_value(value) in {\"number\", \"date\", \"bool\"}\n )\n return strings * 2 + len(non_empty) - data_like * 3, len(non_empty)\n\n\ndef _header_candidate_score(grid: CsvGrid, index: int) -> int:\n score, _ = _header_score(grid.values[index])\n following = grid.values[index + 1 : index + 4]\n following_non_empty = [value for row in following for value in row if value.strip()]\n following_data_like = sum(\n 1 for value in following_non_empty if _classify_value(value) in {\"number\", \"date\", \"bool\"}\n )\n # A schema row is usually followed by data-shaped values; use this as a\n # small tie-breaker while preferring earlier candidates with equal evidence.\n score += min(3, following_data_like)\n return score - index // 2\n\n\ndef detect_header_row(grid: CsvGrid, scan_rows: int = 20) -> int | None:\n best_row = None\n best_score = None\n for idx, row in enumerate(grid.values[:scan_rows]):\n _, non_empty_count = _header_score(row)\n if not non_empty_count:\n continue\n score = _header_candidate_score(grid, idx)\n if best_score is None or score > best_score:\n best_score = score\n best_row = grid.row_numbers[idx]\n return best_row\n\n\ndef _possible_multi_row_header(grid: CsvGrid, header_row: int | None) -> bool:\n if header_row is None or header_row not in grid.row_numbers:\n return False\n header_index = grid.row_numbers.index(header_row)\n if header_index + 1 >= len(grid.values):\n return False\n header_score, header_non_empty = _header_score(grid.values[header_index])\n next_score, next_non_empty = _header_score(grid.values[header_index + 1])\n if header_non_empty \u003c 2 or next_non_empty \u003c 2:\n return False\n return next_score >= max(4, (header_score * 3 + 3) // 4)\n\n\ndef _header_row_not_first(grid: CsvGrid, header_row: int | None) -> bool:\n if header_row is None:\n return False\n first_non_empty = next(\n (\n row_num\n for row_num, row in zip(grid.row_numbers, grid.values)\n if any(value.strip() for value in row)\n ),\n None,\n )\n return first_non_empty is not None and first_non_empty != header_row\n\n\ndef detect_special_rows(grid: CsvGrid, header_row: int | None) -> list[dict[str, Any]]:\n specials = []\n for idx, row in enumerate(grid.values):\n row_num = grid.row_numbers[idx]\n if header_row is not None and row_num \u003c= header_row:\n continue\n joined = \" \".join(value.strip() for value in row if value.strip())\n if not joined:\n specials.append({\"row\": row_num, \"reason\": \"empty_row\", \"sample\": \"\"})\n continue\n if any(keyword in joined for keyword in TOTAL_KEYWORDS) or TOTAL_EN_RE.search(joined):\n specials.append({\"row\": row_num, \"reason\": \"total_row\", \"sample\": joined[:120]})\n elif any(keyword in joined for keyword in SIGNATURE_KEYWORDS):\n specials.append({\"row\": row_num, \"reason\": \"signature_row\", \"sample\": joined[:120]})\n return specials\n\n\ndef _last_data_row(grid: CsvGrid, header_row: int | None, specials: list[dict[str, Any]]) -> int | None:\n special_rows = {item[\"row\"] for item in specials if item[\"reason\"] != \"empty_row\"}\n for idx in range(len(grid.row_numbers) - 1, -1, -1):\n row_num = grid.row_numbers[idx]\n if header_row is not None and row_num \u003c= header_row:\n continue\n if row_num in special_rows:\n continue\n if any(value.strip() for value in grid.values[idx]):\n return row_num\n return None\n\n\ndef _column_values(grid: CsvGrid, col_index: int, start_row: int, end_row: int | None) -> list[str]:\n values = []\n for idx, row_num in enumerate(grid.row_numbers):\n if row_num \u003c start_row:\n continue\n if end_row is not None and row_num > end_row:\n continue\n row = grid.values[idx]\n values.append(row[col_index] if col_index \u003c len(row) else \"\")\n return values\n\n\ndef _type_guess(type_counts: dict[str, int]) -> str:\n non_empty_counts = {key: value for key, value in type_counts.items() if key != \"empty\" and value}\n if not non_empty_counts:\n return \"empty\"\n if len(non_empty_counts) == 1:\n return next(iter(non_empty_counts))\n if \"error\" in non_empty_counts:\n return \"mixed_with_errors\"\n if \"string_id\" in non_empty_counts and set(non_empty_counts) \u003c= {\"string_id\", \"string\"}:\n return \"string_id\"\n return \"mixed\"\n\n\ndef profile_columns(\n grid: CsvGrid,\n *,\n header_row: int | None,\n data_start_row: int,\n data_end_row: int | None,\n) -> list[dict[str, Any]]:\n header_values = []\n if header_row in grid.row_numbers:\n header_values = _row_values(grid, grid.row_numbers.index(header_row))\n\n profiles = []\n for idx, col in enumerate(grid.col_letters):\n name = \"\"\n if idx \u003c len(header_values):\n name = header_values[idx].strip()\n if not name:\n name = f\"unnamed_{col}\"\n\n values = _column_values(grid, idx, data_start_row, data_end_row)\n type_counts: dict[str, int] = {}\n examples = []\n error_count = 0\n for value in values:\n value_type = _classify_value(value)\n type_counts[value_type] = type_counts.get(value_type, 0) + 1\n if value_type == \"error\":\n error_count += 1\n if value.strip() and len(examples) \u003c 5:\n examples.append(value)\n non_empty = len(values) - type_counts.get(\"empty\", 0)\n warnings = []\n if type_counts.get(\"string_id\"):\n warnings.append(\"long_numeric_like_id\")\n if error_count:\n warnings.append(\"formula_or_value_errors\")\n if non_empty and type_counts.get(\"empty\", 0) / len(values) > 0.5:\n warnings.append(\"many_empty_cells\")\n if _type_guess(type_counts) == \"mixed\":\n warnings.append(\"mixed_value_types\")\n\n profiles.append(\n {\n \"name\": name,\n \"col\": col,\n \"non_empty\": non_empty,\n \"empty\": type_counts.get(\"empty\", 0),\n \"type_guess\": _type_guess(type_counts),\n \"type_distribution\": type_counts,\n \"examples\": examples,\n \"warnings\": warnings,\n }\n )\n return profiles\n\n\ndef _field_map(columns: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:\n result: dict[str, list[dict[str, Any]]] = {}\n for column in columns:\n name = column[\"name\"]\n result.setdefault(name, []).append(\n {\n \"col\": column[\"col\"],\n \"type_guess\": column[\"type_guess\"],\n \"warnings\": column[\"warnings\"],\n }\n )\n return result\n\n\ndef _risk_warnings(\n *,\n columns: list[dict[str, Any]],\n special_rows: list[dict[str, Any]],\n header_row: int | None,\n data_range: str | None,\n possible_multi_row_header: bool,\n header_row_not_first: bool,\n data_range_has_gaps: bool,\n data_range_has_col_gaps: bool,\n hidden_rows: list[int],\n hidden_columns: list[str],\n) -> list[str]:\n warnings = {warning for column in columns for warning in column[\"warnings\"]}\n names = [column[\"name\"] for column in columns]\n if len(names) != len(set(names)):\n warnings.add(\"duplicate_headers\")\n if any(name.startswith(\"unnamed_\") for name in names):\n warnings.add(\"unnamed_columns\")\n if header_row is None:\n warnings.add(\"header_not_detected\")\n if data_range is None:\n warnings.add(\"data_range_not_detected\")\n if any(item[\"reason\"] != \"empty_row\" for item in special_rows):\n warnings.add(\"special_rows_present\")\n if any(item[\"reason\"] == \"empty_row\" for item in special_rows):\n warnings.add(\"empty_rows_present\")\n if possible_multi_row_header:\n warnings.add(\"possible_multi_row_header\")\n if header_row_not_first:\n warnings.add(\"header_row_not_first\")\n if data_range_has_gaps:\n warnings.add(\"data_range_has_gaps\")\n if data_range_has_col_gaps:\n warnings.add(\"data_range_has_col_gaps\")\n if hidden_rows:\n warnings.add(\"hidden_rows_in_range\")\n if hidden_columns:\n warnings.add(\"hidden_columns_in_range\")\n return sorted(warnings)\n\n\ndef _col_segments(col_letters: list[str]) -> list[list[str]]:\n \"\"\"Group column letters into contiguous runs of real column indices.\n\n With --skip-hidden the returned columns can be non-consecutive (A, C when B\n is hidden). Reporting a single data_range across that gap would let a caller\n write the hidden-column-free data back as if it were contiguous, shifting\n every value right of the gap.\n \"\"\"\n if not col_letters:\n return []\n indices = [col_to_index(col) for col in col_letters]\n segments = []\n start = previous = indices[0]\n for current in indices[1:]:\n if current == previous + 1:\n previous = current\n continue\n segments.append([index_to_col(start), index_to_col(previous)])\n start = previous = current\n segments.append([index_to_col(start), index_to_col(previous)])\n return segments\n\n\ndef _row_segments(row_numbers: list[int]) -> list[list[int]]:\n if not row_numbers:\n return []\n segments = []\n start = end = row_numbers[0]\n for row in row_numbers[1:]:\n if row == end + 1:\n end = row\n continue\n segments.append([start, end])\n start = end = row\n segments.append([start, end])\n return segments\n\n\ndef _write_hints(\n grid: CsvGrid,\n *,\n header_row: int | None,\n data_start: int,\n bounds,\n hidden_columns: list[str] | None = None,\n skip_hidden: bool = False,\n) -> dict[str, Any]:\n last_non_empty_col = None\n for idx, col in enumerate(grid.col_letters):\n if any(idx \u003c len(row) and row[idx].strip() for row in grid.values):\n last_non_empty_col = col_to_index(col)\n safe_col_num = (last_non_empty_col + 1) if last_non_empty_col else bounds.start_col\n # Step over hidden columns. Under --skip-hidden they are absent from the\n # grid entirely, so \"one past the last visible column\" can land ON a hidden\n # column that holds data — and when the hidden columns sit at the right edge\n # there is no gap in the returned letters either, so data_range_has_col_gaps\n # stays silent too. Appending there would overwrite data nobody can see.\n hidden_indices = {col_to_index(col) for col in (hidden_columns or [])}\n skipped_hidden: list[str] = []\n while safe_col_num in hidden_indices:\n skipped_hidden.append(index_to_col(safe_col_num))\n safe_col_num += 1\n safe_col = index_to_col(safe_col_num)\n hints = {\n \"last_non_empty_col\": index_to_col(last_non_empty_col) if last_non_empty_col else None,\n }\n if not (skip_hidden and hidden_columns is None):\n hints.update(\n {\n \"safe_append_col\": safe_col,\n \"safe_append_header_cell\": f\"{safe_col}{header_row}\" if header_row else None,\n \"safe_append_data_start_cell\": f\"{safe_col}{data_start}\",\n }\n )\n if skipped_hidden:\n hints[\"skipped_hidden_cols\"] = skipped_hidden\n return hints\n\n\ndef profile_grid(\n grid: CsvGrid,\n source_range: str,\n *,\n skip_hidden: bool = False,\n hidden_rows: list[int] | None = None,\n hidden_columns: list[str] | None = None,\n all_hidden_columns: list[str] | None = None,\n header_scan_rows: int = 20,\n) -> dict[str, Any]:\n max_row = max(grid.row_numbers, default=1)\n max_col = max((col_to_index(col) for col in grid.col_letters), default=1)\n bounds = parse_range(source_range, max_row=max_row, max_col=max_col)\n header_row = detect_header_row(grid, header_scan_rows)\n data_start = (header_row + 1) if header_row else bounds.start_row\n specials = detect_special_rows(grid, header_row)\n data_end = _last_data_row(grid, header_row, specials)\n data_range = None\n if data_end is not None and data_end >= data_start:\n data_range = format_range(data_start, bounds.start_col, data_end, bounds.end_col)\n columns = profile_columns(\n grid,\n header_row=header_row,\n data_start_row=data_start,\n data_end_row=data_end,\n )\n data_row_numbers = []\n if data_end is not None and data_end >= data_start:\n data_row_numbers = [\n row for row in grid.row_numbers if data_start \u003c= row \u003c= data_end\n ]\n data_row_segments = _row_segments(data_row_numbers)\n data_range_has_gaps = bool(data_row_numbers) and (\n data_row_numbers[0] != data_start or len(data_row_segments) > 1\n )\n data_rows = len(data_row_numbers)\n data_col_segments = _col_segments(grid.col_letters)\n data_range_has_col_gaps = len(data_col_segments) > 1\n hidden_rows = hidden_rows or []\n hidden_columns = hidden_columns or []\n possible_multi_row_header = _possible_multi_row_header(grid, header_row)\n header_row_not_first = _header_row_not_first(grid, header_row)\n\n return {\n \"summary\": {\n \"range\": source_range,\n \"header_row\": header_row,\n \"data_range\": data_range,\n \"data_rows\": data_rows,\n \"data_row_segments\": data_row_segments,\n \"data_col_segments\": data_col_segments,\n \"column_count\": len(columns),\n \"special_rows_count\": len(specials),\n },\n \"range\": source_range,\n \"header_row\": header_row,\n \"data_range\": data_range,\n \"data_row_segments\": data_row_segments,\n \"data_col_segments\": data_col_segments,\n \"columns\": columns,\n \"field_map\": _field_map(columns),\n \"risk_warnings\": _risk_warnings(\n columns=columns,\n special_rows=specials,\n header_row=header_row,\n data_range=data_range,\n possible_multi_row_header=possible_multi_row_header,\n header_row_not_first=header_row_not_first,\n data_range_has_gaps=data_range_has_gaps,\n data_range_has_col_gaps=data_range_has_col_gaps,\n hidden_rows=hidden_rows,\n hidden_columns=hidden_columns,\n ),\n \"visibility\": {\n \"skip_hidden\": skip_hidden,\n \"hidden_rows_in_range\": hidden_rows,\n \"hidden_columns_in_range\": hidden_columns,\n },\n \"write_hints\": _write_hints(\n grid,\n header_row=header_row,\n data_start=data_start,\n bounds=bounds,\n hidden_columns=all_hidden_columns,\n skip_hidden=skip_hidden,\n ),\n \"special_rows\": specials,\n }\n\n\ndef _hidden_rows_and_columns(grid: CsvGrid, layout: dict[str, Any]) -> tuple[list[int], list[str]]:\n def indexes(key: str) -> set[int]:\n values = layout.get(key)\n if not isinstance(values, list):\n return set()\n result = set()\n for value in values:\n try:\n result.add(int(value) + 1) # +sheet-info uses zero-based indices.\n except (TypeError, ValueError):\n continue\n return result\n\n hidden_row_indexes = indexes(\"hidden_rows\")\n hidden_columns = layout.get(\"hidden_cols\") or layout.get(\"hidden_columns\") or []\n hidden_column_letters = set()\n for value in hidden_columns if isinstance(hidden_columns, list) else []:\n if isinstance(value, str) and value.isalpha():\n hidden_column_letters.add(value.upper())\n else:\n try:\n hidden_column_letters.add(index_to_col(int(value) + 1))\n except (TypeError, ValueError):\n continue\n rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)\n columns = [col for col in grid.col_letters if col.upper() in hidden_column_letters]\n return rows, columns\n\n\ndef _all_hidden_columns(layout: dict[str, Any]) -> list[str]:\n \"\"\"Every hidden column on the sheet, not just those inside the grid.\n\n _hidden_rows_and_columns intersects with the returned grid, which is the\n right scope for the \"there are hidden rows/columns in what you read\"\n warnings. The append hint needs the opposite: the columns that are NOT in\n the grid precisely because they are hidden.\n \"\"\"\n letters: list[str] = []\n raw = layout.get(\"hidden_cols\") or layout.get(\"hidden_columns\") or []\n for value in raw if isinstance(raw, list) else []:\n if isinstance(value, str) and value.isalpha():\n letters.append(value.upper())\n continue\n try:\n letters.append(index_to_col(int(value) + 1))\n except (TypeError, ValueError):\n continue\n return letters\n\n\ndef profile_table(args) -> tuple[dict[str, Any], list[str]]:\n warnings = []\n if args.header_scan_rows \u003c 1:\n raise ValueError(\"--header-scan-rows must be at least 1\")\n csv_data = envelope_data(\n run_sheets(\n \"+csv-get\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n sheet_id=args.sheet_id,\n sheet_name=args.sheet_name,\n flags={\n \"range\": args.range,\n \"max_chars\": args.max_chars,\n \"skip_hidden\": True if args.skip_hidden else None,\n },\n timeout=args.timeout,\n )\n )\n source_range = str(csv_data.get(\"actual_range\") or args.range)\n if csv_data.get(\"has_more\"):\n raise LarkCliError(\n f\"+csv-get truncated the requested range at {source_range}; narrow --range before profiling\"\n )\n grid = parse_annotated_csv(\n csv_data.get(\"annotated_csv\", \"\"),\n csv_data.get(\"col_indices\"),\n csv_data.get(\"row_indices\"),\n source_range,\n )\n if grid.row_numbers_inferred:\n warnings.append(\"CSV row numbers were inferred from the requested range\")\n hidden_rows: list[int] = []\n hidden_columns: list[str] = []\n all_hidden_columns: list[str] | None = None\n # Fetched in BOTH modes. Under --skip-hidden the hidden rows/columns are\n # absent from the grid, so hidden_rows/hidden_columns (which are scoped to\n # what the grid contains) come back empty and no warning fires — but the\n # write hints still need to know where the hidden columns are, or\n # safe_append_col can point at one that holds data. all_hidden_columns is\n # the unscoped list used for exactly that.\n try:\n layout = envelope_data(\n run_sheets(\n \"+sheet-info\",\n url=args.url,\n spreadsheet_token=args.spreadsheet_token,\n sheet_id=args.sheet_id,\n sheet_name=args.sheet_name,\n flags={\"include\": \"hidden_rows,hidden_cols\"},\n timeout=args.timeout,\n )\n )\n hidden_rows, hidden_columns = _hidden_rows_and_columns(grid, layout)\n all_hidden_columns = _all_hidden_columns(layout)\n except LarkCliError as exc:\n warnings.append(f\"hidden row/column detection unavailable: {exc}\")\n return profile_grid(\n grid,\n source_range,\n skip_hidden=args.skip_hidden,\n hidden_rows=hidden_rows,\n hidden_columns=hidden_columns,\n all_hidden_columns=all_hidden_columns,\n header_scan_rows=args.header_scan_rows,\n ), warnings\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=__doc__)\n add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)\n parser.add_argument(\"--range\", required=True)\n parser.add_argument(\"--max-chars\", type=int, default=25000)\n parser.add_argument(\"--header-scan-rows\", type=int, default=20)\n parser.add_argument(\"--skip-hidden\", action=\"store_true\")\n parser.add_argument(\"--timeout\", type=int, default=60)\n args = parser.parse_args()\n try:\n data, warnings = profile_table(args)\n except (LarkCliError, ValueError, TypeError) as exc:\n emit_error(ACTION, str(exc))\n emit_success(ACTION, data, warnings)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22678,"content_sha256":"3fa4f0aae050b143794dbacd7d299986bf21320cbbbe0b24f69f619a06664519"},{"filename":"scripts/lark_sheet_range.py","content":"# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Range and coordinate helpers for Lark Sheet scripts.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass\n\nCELL_RE = re.compile(r\"^\\$?([A-Za-z]+)\\$?([1-9][0-9]*)$\")\nROW_RE = re.compile(r\"^\\$?([1-9][0-9]*)$\")\nCOLUMN_RE = re.compile(r\"^\\$?([A-Za-z]+)$\")\n\n\n@dataclass(frozen=True)\nclass RangeBounds:\n start_row: int\n start_col: int\n end_row: int\n end_col: int\n\n @property\n def row_count(self) -> int:\n return self.end_row - self.start_row + 1\n\n @property\n def col_count(self) -> int:\n return self.end_col - self.start_col + 1\n\n\ndef col_to_index(col: str) -> int:\n value = 0\n for char in col.strip().upper():\n if not (\"A\" \u003c= char \u003c= \"Z\"):\n raise ValueError(f\"Invalid column: {col}\")\n value = value * 26 + (ord(char) - ord(\"A\") + 1)\n if value \u003c= 0:\n raise ValueError(f\"Invalid column: {col}\")\n return value\n\n\ndef index_to_col(index: int) -> str:\n if index \u003c 1:\n raise ValueError(f\"Column index must be >= 1: {index}\")\n chars = []\n n = index\n while n:\n n, rem = divmod(n - 1, 26)\n chars.append(chr(ord(\"A\") + rem))\n return \"\".join(reversed(chars))\n\n\ndef parse_cell(cell_ref: str) -> tuple[int, int]:\n match = CELL_RE.match(cell_ref.strip())\n if not match:\n raise ValueError(f\"Invalid cell reference: {cell_ref}\")\n col, row = match.groups()\n return int(row), col_to_index(col)\n\n\ndef _parse_endpoint(endpoint: str) -> tuple[str, int, int] | tuple[str, int]:\n cell = CELL_RE.match(endpoint)\n if cell:\n col, row = cell.groups()\n return \"cell\", int(row), col_to_index(col)\n row = ROW_RE.match(endpoint)\n if row:\n return \"row\", int(row.group(1))\n column = COLUMN_RE.match(endpoint)\n if column:\n return \"column\", col_to_index(column.group(1))\n raise ValueError(f\"Invalid A1 range endpoint: {endpoint}\")\n\n\ndef parse_range(\n range_ref: str,\n *,\n max_row: int | None = None,\n max_col: int | None = None,\n) -> RangeBounds:\n \"\"\"Parse the A1 range forms accepted by ``+csv-get``.\n\n Open-ended forms need the caller's actual returned grid dimensions. This\n keeps generated ranges finite without guessing a spreadsheet-wide limit.\n \"\"\"\n ref = range_ref.strip()\n if \"!\" in ref:\n _, ref = ref.rsplit(\"!\", 1)\n if not ref:\n raise ValueError(f\"Invalid A1 range: {range_ref}\")\n parts = ref.split(\":\")\n if len(parts) > 2:\n raise ValueError(f\"Invalid A1 range: {range_ref}\")\n start = _parse_endpoint(parts[0])\n end = _parse_endpoint(parts[-1])\n\n if len(parts) == 1:\n if start[0] != \"cell\":\n raise ValueError(f\"A1 range must include a cell or ':' separator: {range_ref}\")\n _, row, col = start\n return RangeBounds(row, col, row, col)\n\n if start[0] == end[0] == \"cell\":\n _, start_row, start_col = start\n _, end_row, end_col = end\n elif start[0] == end[0] == \"row\":\n _, start_row = start\n _, end_row = end\n start_col = 1\n if max_col is None:\n raise ValueError(f\"Range needs a maximum column: {range_ref}\")\n end_col = max_col\n elif start[0] == end[0] == \"column\":\n _, start_col = start\n _, end_col = end\n start_row = 1\n if max_row is None:\n raise ValueError(f\"Range needs a maximum row: {range_ref}\")\n end_row = max_row\n elif start[0] == \"cell\" and end[0] == \"column\":\n _, start_row, start_col = start\n _, end_col = end\n if max_row is None:\n raise ValueError(f\"Range needs a maximum row: {range_ref}\")\n end_row = max(start_row, max_row)\n elif start[0] == \"cell\" and end[0] == \"row\":\n _, start_row, start_col = start\n _, end_row = end\n if max_col is None:\n raise ValueError(f\"Range needs a maximum column: {range_ref}\")\n end_col = max(start_col, max_col)\n else:\n raise ValueError(f\"Invalid A1 range: {range_ref}\")\n return RangeBounds(min(start_row, end_row), min(start_col, end_col), max(start_row, end_row), max(start_col, end_col))\n\n\ndef format_cell(row: int, col: int) -> str:\n if row \u003c 1:\n raise ValueError(f\"Row must be >= 1: {row}\")\n return f\"{index_to_col(col)}{row}\"\n\n\ndef format_range(start_row: int, start_col: int, end_row: int, end_col: int) -> str:\n bounds = RangeBounds(\n min(start_row, end_row),\n min(start_col, end_col),\n max(start_row, end_row),\n max(start_col, end_col),\n )\n start = format_cell(bounds.start_row, bounds.start_col)\n end = format_cell(bounds.end_row, bounds.end_col)\n return start if start == end else f\"{start}:{end}\"\n\n\ndef iter_cells(bounds: RangeBounds):\n for row in range(bounds.start_row, bounds.end_row + 1):\n for col in range(bounds.start_col, bounds.end_col + 1):\n yield row, col\n\n\ndef ranges_intersect(a: RangeBounds, b: RangeBounds) -> bool:\n return not (\n a.end_row \u003c b.start_row\n or b.end_row \u003c a.start_row\n or a.end_col \u003c b.start_col\n or b.end_col \u003c a.start_col\n )\n\n\ndef range_union(a: RangeBounds, b: RangeBounds) -> RangeBounds:\n return RangeBounds(\n min(a.start_row, b.start_row),\n min(a.start_col, b.start_col),\n max(a.end_row, b.end_row),\n max(a.end_col, b.end_col),\n )\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5444,"content_sha256":"dc54be144daeb4d3bc8dab7db72b72ddc23e00f9fb6a77bc91acbf5c0644e8ac"},{"filename":"scripts/lark_sheet_read_cli.py","content":"# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"Read-only Lark Sheet subset wrapper for the helper scripts.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport subprocess\nimport sys\nfrom typing import Any\n\n\nclass LarkCliError(RuntimeError):\n def __init__(self, message: str, *, cmd: list[str] | None = None):\n super().__init__(message)\n self.cmd = cmd or []\n\n\ndef add_spreadsheet_args(\n parser,\n *,\n require_sheet: bool = False,\n allow_sheet: bool = True,\n) -> None:\n spreadsheet = parser.add_mutually_exclusive_group(required=True)\n spreadsheet.add_argument(\"--url\")\n spreadsheet.add_argument(\"--spreadsheet-token\")\n if allow_sheet:\n sheet = parser.add_mutually_exclusive_group(required=require_sheet)\n sheet.add_argument(\"--sheet-id\")\n sheet.add_argument(\"--sheet-name\")\n\n\ndef _append_flag(cmd: list[str], name: str, value: Any) -> None:\n flag = f\"--{name.replace('_', '-')}\"\n if value is None:\n return\n if isinstance(value, bool):\n cmd.append(flag if value else f\"{flag}=false\")\n return\n cmd.extend([flag, str(value)])\n\n\ndef run_sheets(\n shortcut: str,\n *,\n url: str | None = None,\n spreadsheet_token: str | None = None,\n sheet_id: str | None = None,\n sheet_name: str | None = None,\n flags: dict[str, Any] | None = None,\n timeout: int = 60,\n) -> dict[str, Any]:\n if bool(url) == bool(spreadsheet_token):\n raise LarkCliError(\"Pass exactly one of --url or --spreadsheet-token\")\n if sheet_id and sheet_name:\n raise LarkCliError(\"Pass only one of --sheet-id or --sheet-name\")\n\n cmd = [\"lark-cli\", \"sheets\", shortcut]\n _append_flag(cmd, \"url\", url)\n _append_flag(cmd, \"spreadsheet_token\", spreadsheet_token)\n _append_flag(cmd, \"sheet_id\", sheet_id)\n _append_flag(cmd, \"sheet_name\", sheet_name)\n for key, value in (flags or {}).items():\n _append_flag(cmd, key, value)\n\n try:\n completed = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=timeout,\n check=False,\n )\n except FileNotFoundError as exc:\n raise LarkCliError(\"lark-cli not found\", cmd=cmd) from exc\n except subprocess.TimeoutExpired as exc:\n raise LarkCliError(f\"lark-cli timed out after {timeout}s\", cmd=cmd) from exc\n\n if completed.returncode != 0:\n detail = (completed.stderr or completed.stdout or \"\").strip()\n raise LarkCliError(detail or f\"lark-cli exited with {completed.returncode}\", cmd=cmd)\n\n try:\n envelope = json.loads(completed.stdout)\n except json.JSONDecodeError as exc:\n snippet = completed.stdout[:500].replace(\"\\n\", \"\\\\n\")\n raise LarkCliError(f\"lark-cli stdout was not JSON: {snippet}\", cmd=cmd) from exc\n\n if isinstance(envelope, dict) and envelope.get(\"ok\") is False:\n raise LarkCliError(json.dumps(envelope, ensure_ascii=False), cmd=cmd)\n if not isinstance(envelope, dict):\n raise LarkCliError(\"lark-cli returned a non-object JSON payload\", cmd=cmd)\n return envelope\n\n\ndef envelope_data(envelope: dict[str, Any]) -> dict[str, Any]:\n data = envelope.get(\"data\")\n return data if isinstance(data, dict) else envelope\n\n\ndef emit_success(action: str, data: dict[str, Any], warnings: list[str] | None = None) -> None:\n print(\n json.dumps(\n {\n \"ok\": True,\n \"engine\": \"lark\",\n \"action\": action,\n \"data\": data,\n \"warnings\": warnings or [],\n },\n ensure_ascii=False,\n indent=2,\n )\n )\n\n\ndef emit_error(action: str, message: str, warnings: list[str] | None = None) -> None:\n print(\n json.dumps(\n {\n \"ok\": False,\n \"engine\": \"lark\",\n \"action\": action,\n \"error\": message,\n \"warnings\": warnings or [],\n },\n ensure_ascii=False,\n indent=2,\n )\n )\n sys.exit(1)\n\n\ndef sheet_title(sheet: dict[str, Any]) -> str:\n return str(sheet.get(\"title\") or sheet.get(\"sheet_name\") or sheet.get(\"name\") or \"\")\n\n\ndef sheet_identifier(sheet: dict[str, Any]) -> str:\n return str(sheet.get(\"sheet_id\") or sheet.get(\"id\") or \"\")\n\n\ndef sheet_locator(sheet: dict[str, Any]) -> dict[str, str]:\n sid = sheet_identifier(sheet)\n if sid:\n return {\"sheet_id\": sid}\n title = sheet_title(sheet)\n if title:\n return {\"sheet_name\": title}\n return {}\n\n\ndef extract_sheets(workbook_data: dict[str, Any]) -> list[dict[str, Any]]:\n sheets = workbook_data.get(\"sheets\")\n if isinstance(sheets, list):\n return [sheet for sheet in sheets if isinstance(sheet, dict)]\n workbook = workbook_data.get(\"workbook\")\n if isinstance(workbook, dict) and isinstance(workbook.get(\"sheets\"), list):\n return [sheet for sheet in workbook[\"sheets\"] if isinstance(sheet, dict)]\n return []\n\n\ndef sheet_resource_type(sheet: dict[str, Any]) -> str:\n return str(sheet.get(\"resource_type\") or \"\")\n\n\ndef is_grid_sheet(sheet: dict[str, Any]) -> bool:\n resource_type = sheet_resource_type(sheet)\n if resource_type == \"sheet\":\n return True\n # Legacy responses may omit resource_type but still include grid dimensions.\n return not resource_type and sheet.get(\"row_count\") is not None and sheet.get(\"column_count\") is not None\n\n\ndef visible_grid_selection(\n workbook_data: dict[str, Any],\n *,\n sheet_id: str | None = None,\n sheet_name: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Resolve explicit targets or publish a safe default-selection decision.\n\n Unspecified targets are never selected by index: only visible ordinary grids\n participate, and multiple candidates remain ambiguous for the caller to match\n by task wording or ask the user.\n \"\"\"\n sheets = extract_sheets(workbook_data)\n specified = {\"sheet_id\": sheet_id, \"sheet_name\": sheet_name}\n if sheet_id or sheet_name:\n matches = resolve_target_sheets(workbook_data, sheet_id=sheet_id, sheet_name=sheet_name, require_one=True)\n sheet = matches[0]\n if not is_grid_sheet(sheet):\n raise LarkCliError(f\"Sheet {sheet_title(sheet) or sheet_identifier(sheet)} is not a grid sheet; use the matching product API\")\n warnings = [\"explicit_hidden_sheet\"] if sheet.get(\"is_hidden\") is True else []\n return {\"policy\": \"visible_grid_v1\", \"specified\": specified, \"selected\": _selection_sheet(sheet), \"candidates\": [_selection_sheet(sheet)], \"excluded\": [], \"ambiguous\": False, \"warnings\": warnings, \"next_action\": \"use selected sheet_id for grid read/write\"}\n\n candidates, excluded = [], []\n for sheet in sheets:\n if not is_grid_sheet(sheet):\n excluded.append({**_selection_sheet(sheet), \"reason\": \"non_grid\"})\n elif sheet.get(\"is_hidden\") is True:\n excluded.append({**_selection_sheet(sheet), \"reason\": \"hidden\"})\n elif sheet.get(\"is_hidden\") is not False:\n excluded.append({**_selection_sheet(sheet), \"reason\": \"visibility_unknown\"})\n else:\n candidates.append(_selection_sheet(sheet))\n candidates.sort(key=lambda item: item.get(\"index\") if isinstance(item.get(\"index\"), int) else 10**9)\n selected = candidates[0] if len(candidates) == 1 else None\n return {\"policy\": \"visible_grid_v1\", \"specified\": specified, \"selected\": selected, \"candidates\": candidates, \"excluded\": excluded, \"ambiguous\": len(candidates) > 1, \"warnings\": [], \"next_action\": \"use selected sheet_id for grid read/write\" if selected else \"match task wording/title/header; do not choose by index\"}\n\n\ndef _selection_sheet(sheet: dict[str, Any]) -> dict[str, Any]:\n return {\"sheet_id\": sheet_identifier(sheet), \"title\": sheet_title(sheet), \"index\": sheet.get(\"index\"), \"resource_type\": sheet_resource_type(sheet) or \"legacy_grid\", \"is_hidden\": sheet.get(\"is_hidden\"), \"row_count\": sheet.get(\"row_count\"), \"column_count\": sheet.get(\"column_count\")}\n\n\ndef resolve_target_sheets(\n workbook_data: dict[str, Any],\n *,\n sheet_id: str | None = None,\n sheet_name: str | None = None,\n require_one: bool = False,\n) -> list[dict[str, Any]]:\n sheets = extract_sheets(workbook_data)\n if sheet_id:\n matches = [sheet for sheet in sheets if sheet_identifier(sheet) == sheet_id]\n elif sheet_name:\n matches = [sheet for sheet in sheets if sheet_title(sheet) == sheet_name]\n else:\n matches = sheets\n\n if require_one:\n if len(matches) == 1:\n return matches\n if not matches:\n raise LarkCliError(\"No matching sheet found\")\n raise LarkCliError(\"Multiple sheets matched; pass --sheet-id or --sheet-name\")\n return matches\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8815,"content_sha256":"35e81c2978273e6163b4e6b3cedd27c78694fed05ee21ec5adb65d91357481dd"},{"filename":"scripts/lark_sheets_df.py","content":"#!/usr/bin/env python3\n# Copyright (c) 2026 Lark Technologies Pte. Ltd.\n# SPDX-License-Identifier: MIT\n\"\"\"DataFrame ↔ Feishu Sheet typed-JSON helpers.\n\nThis is the same 7-line snippet the skill docs already inline (see\n`lark-sheets-write-cells` \"DataFrame → 协议(5 行 helper)\" and\n`lark-sheets-read-data` \"输出 → DataFrame(2 行 helper)\"), pulled out\nso callers can `import` it instead of copy-pasting:\n\n from lark_sheets_df import df_to_sheet, sheet_to_df\n\nCallers run lark-cli themselves; this file is a library, not a CLI.\n\"\"\"\nimport json\n\nimport pandas as pd\n\n\ndef df_to_sheet(df, name, formats=None):\n \"\"\"Pack one DataFrame into one entry of a `+table-put --sheets` payload.\"\"\"\n packed = json.loads(df.to_json(orient=\"split\", date_format=\"iso\"))\n # The protocol requires string column names. pandas keeps integer labels\n # (e.g. the default RangeIndex columns 0/1/2) as JSON numbers, while the\n # dtypes dict keys get stringified during JSON serialization — the CLI\n # then rejects `columns` (\"cannot unmarshal number into … type string\")\n # and the dtype lookup would miss anyway. Stringify every key once, and\n # refuse to continue when that conversion silently merges two columns.\n normalized_labels = [str(c) for c in df.columns]\n columns = [str(c) for c in packed[\"columns\"]]\n if normalized_labels != columns:\n columns = normalized_labels\n if len(set(columns)) != len(columns):\n raise ValueError(\n \"column labels collide after str() conversion; \"\n \"rename the DataFrame columns before packing\"\n )\n packed[\"columns\"] = columns\n dtype_values = list(df.dtypes)\n return {\n \"name\": name,\n **packed,\n \"dtypes\": {key: str(dtype) for key, dtype in zip(columns, dtype_values)},\n **({\"formats\": {str(k): v for k, v in formats.items()}} if formats else {}),\n }\n\n\ndef sheet_to_df(sheet):\n \"\"\"Restore one `+table-get` sheet dict into a typed DataFrame.\"\"\"\n return pd.DataFrame(sheet[\"data\"], columns=sheet[\"columns\"]).astype(sheet[\"dtypes\"])\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2090,"content_sha256":"b53fcf772b702a896d3969ddd35c25c50f299dfb210a9dd058122afe7f605373"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"sheets","type":"text"}]},{"type":"paragraph","content":[{"text":"CRITICAL — 开始前 MUST 先用 Read 工具读取 ","type":"text","marks":[{"type":"strong"}]},{"text":"../lark-shared/SKILL.md","type":"text","marks":[{"type":"link","attrs":{"href":"../lark-shared/SKILL.md","title":null}},{"type":"code_inline"},{"type":"strong"}]},{"text":",其中包含认证、权限处理。","type":"text","marks":[{"type":"strong"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"场景 → 命令速查","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"按当前动作选行;下一步必须 Read 该行 reference,读取完成前不得执行命令。只读命中的文档;含公式 / 样式等横切动作时再读对应规范,禁止用目录枚举代替 Read。","type":"text"}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"你要做的事","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"✅ 正确写法","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"动手前读(先 Read 再动手)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读数据","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+csv-get","type":"text","marks":[{"type":"code_inline"}]},{"text":"(纯值/CSV)、","type":"text"},{"text":"+cells-get","type":"text","marks":[{"type":"code_inline"}]},{"text":"(公式/样式/批注)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-read-data.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"写入数据","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+csv-put","type":"text","marks":[{"type":"code_inline"}]},{"text":"(无类型歧义纯文本)、","type":"text"},{"text":"+table-put","type":"text","marks":[{"type":"code_inline"}]},{"text":"(typed;量值/真日期;标签/编号/前导零/文本数字用 object,禁裸 csv-put)、","type":"text"},{"text":"+cells-set","type":"text","marks":[{"type":"code_inline"}]},{"text":"(公式/富写入)、","type":"text"},{"text":"+cells-set-style","type":"text","marks":[{"type":"code_inline"}]},{"text":"(样式)、","type":"text"},{"text":"+cells-set-image","type":"text","marks":[{"type":"code_inline"}]},{"text":"(单元格图片)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-write-cells.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"格式继承(新列/新行)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"物理插行 / 插列用 ","type":"text"},{"text":"+dim-insert --inherit-style before|after","type":"text","marks":[{"type":"code_inline"}]},{"text":";往已有空白区域扩写用 ","type":"text"},{"text":"+range-copy --paste-type formats","type":"text","marks":[{"type":"code_inline"}]},{"text":" 先铺样式再写值","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-range-operations.md","type":"text","marks":[{"type":"code_inline"}]},{"text":";插行插列再读 ","type":"text"},{"text":"references/lark-sheets-sheet-structure.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"工作簿操作","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+workbook-create","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+workbook-info","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+workbook-import","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+sheet-copy","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+revision-get","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+workbook-export","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-workbook.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"行列操作","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"排序用 ","type":"text"},{"text":"+range-sort","type":"text","marks":[{"type":"code_inline"}]},{"text":" 原子移动整行;合并 / 取消合并用 ","type":"text"},{"text":"+cells-merge","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+cells-unmerge","type":"text","marks":[{"type":"code_inline"}]},{"text":";清空内容才用 ","type":"text"},{"text":"+cells-clear","type":"text","marks":[{"type":"code_inline"}]},{"text":";尺寸用 ","type":"text"},{"text":"+cols-resize","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+rows-resize","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-range-operations.md","type":"text","marks":[{"type":"code_inline"}]},{"text":";涉结构布局再读 ","type":"text"},{"text":"references/lark-sheets-sheet-structure.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"美化收尾","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+styles-put","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-styles-put.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"子表结构","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+sheet-info","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+dim-insert","type":"text","marks":[{"type":"code_inline"}]},{"text":";删整行 / 列用 ","type":"text"},{"text":"+dim-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":",不能用 clear 代替","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-sheet-structure.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"画图表 / 可视化 / 柱状图 / 折线图 / 饼图 / 趋势 / 占比","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"单图用 ","type":"text"},{"text":"+chart-create-basic","type":"text","marks":[{"type":"code_inline"}]},{"text":",多图用扁平输入的 ","type":"text"},{"text":"+batch-chart-create","type":"text","marks":[{"type":"code_inline"}]},{"text":";改已有图的数据源用 ","type":"text"},{"text":"+chart-data-update","type":"text","marks":[{"type":"code_inline"}]},{"text":"、配置用 ","type":"text"},{"text":"+chart-config-update","type":"text","marks":[{"type":"code_inline"}]},{"text":";只有语义 shortcut 表达不了的单系列 / 单数据点 / 高级字段才用 ","type":"text"},{"text":"+chart-create","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+chart-update","type":"text","marks":[{"type":"code_inline"}]},{"text":",且只提交必要的局部 properties。动手前先断言每张图的类型、横轴字段、分组字段和目标张数,画完 ","type":"text"},{"text":"+chart-list","type":"text","marks":[{"type":"code_inline"}]},{"text":" 逐项核;图片迁移成真图表后删除并复查原浮动图片","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-chart.md","type":"text","marks":[{"type":"code_inline"}]},{"text":";含透视 / 分组汇总再读 ","type":"text"},{"text":"references/lark-sheets-pivot-table.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"分组汇总 / 透视","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+pivot-create","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-pivot-table.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"筛选 / 只看符合条件的行","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+filter-create","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-filter.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"查找 / 替换文本","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+cells-search","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+cells-replace","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-search-replace.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"条件格式 / 条件高亮 / 数据条 / 色阶","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"随数据变化的标色用 ","type":"text"},{"text":"+cond-format-create","type":"text","marks":[{"type":"code_inline"}]},{"text":";固定刷色只用于用户点名要静态着色","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-conditional-format.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"插图:自由摆放的装饰","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+float-image-create","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-float-image.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"迷你图 / 单元格内趋势线","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+sparkline-create","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-sparkline.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"批量清除多区域","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+cells-batch-clear","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-batch-update.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"(high-risk)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"复核编辑变更 / 取版本间差异","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+changeset-get","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-changeset.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"保存多份筛选状态 / 命名筛选视图","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+filter-view-create","type":"text","marks":[{"type":"code_inline"}]},{"text":";视图与 ","type":"text"},{"text":"+filter-create","type":"text","marks":[{"type":"code_inline"}]},{"text":" 相互独立、可在同一子表共存","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-filter-view.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"查编辑历史 / 回滚到历史版本","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+history-list","type":"text","marks":[{"type":"code_inline"}]},{"text":" 取版本,","type":"text"},{"text":"+history-revert","type":"text","marks":[{"type":"code_inline"}]},{"text":"(high-risk,异步)回滚后用 ","type":"text"},{"text":"+history-revert-status","type":"text","marks":[{"type":"code_inline"}]},{"text":" 轮询","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读 ","type":"text"},{"text":"references/lark-sheets-history.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"⚠️ 金额 / 百分比 / 比率 / 计数及参与运算的真日期写数字(百分比传 ","type":"text"},{"text":"0.4","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"number_format","type":"text","marks":[{"type":"code_inline"}]},{"text":");日期标签、编号、前导零、身份证 / 单据号写文本。","type":"text"},{"text":"--range","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只写 ","type":"text"},{"text":"A1:B2","type":"text","marks":[{"type":"code_inline"}]},{"text":",子表另传 ","type":"text"},{"text":"--sheet-id","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--sheet-name","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"飞书表格编辑准则","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"最小改动","type":"text","marks":[{"type":"strong"}]},{"text":":用户没点名要删 / 改名 / 隐藏时,已有 Sheet 一张不动;补齐只写空格,未要求调整的值 / 结构 / 格式不动。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"目标子表与回读断言","type":"text","marks":[{"type":"strong"}]},{"text":":先确认真实末行与目标区域;未点名子表时只从 ","type":"text"},{"text":"resource_type=sheet && is_hidden=false","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的可见网格候选里选,唯一才自动使用,多张不得按 index 猜。涉及\"所有 / 每个 sheet\"(跨表汇总、批量清洗、合并多张子表)时先 ","type":"text"},{"text":"+workbook-info","type":"text","marks":[{"type":"code_inline"}]},{"text":" 列全再逐个处理,别只做前几张。写后用 ","type":"text"},{"text":"+csv-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+cells-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+\u003c对象>-list","type":"text","marks":[{"type":"code_inline"}]},{"text":" 验首、中、末及用户点名项——返回 ","type":"text"},{"text":"ok","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只表示请求成功。纯 CSV 回写前去掉 ","type":"text"},{"text":"annotated_csv","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 ","type":"text"},{"text":"[row=N] ","type":"text","marks":[{"type":"code_inline"}]},{"text":" 前缀,","type":"text"},{"text":"cells-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的样式字段与值分开处理,公式必须回读 ","type":"text"},{"text":"formula","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"},{"text":"样式同样要回读","type":"text","marks":[{"type":"strong"}]},{"text":":写过边框 / 底色 / 字体色 / 数字格式 / 行高列宽 / 冻结的,收尾用 ","type":"text"},{"text":"+cells-get --include style","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":"+sheet-info","type":"text","marks":[{"type":"code_inline"}]},{"text":" 抽查目标区域首、中、末格确认属性真的在——写入返回 ","type":"text"},{"text":"ok","type":"text","marks":[{"type":"code_inline"}]},{"text":" 不代表样式落上了;缺的整份重发(样式是幂等盖章,重发无副作用)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"公式闭环","type":"text","marks":[{"type":"strong"}]},{"text":":可推导值写落格公式,不用静态值代替——用 Python 算好数值再写进单元格,交付的是改输入不重算的死表;Python 只用于推导和验证,落进单元格的必须是引用其他格的公式。写前确认字段语义、阈值边界(以上/至少=","type":"text"},{"text":">=","type":"text","marks":[{"type":"code_inline"}]},{"text":",超过/大于=","type":"text"},{"text":">","type":"text","marks":[{"type":"code_inline"}]},{"text":")、单位/时区和完整源范围,选首中末、空值、边界及一条可手算记录作哨兵;写后逐段 ","type":"text"},{"text":"+formula-verify --exit-on-error","type":"text","marks":[{"type":"code_inline"}]},{"text":",各段 ","type":"text"},{"text":"status='success'","type":"text","marks":[{"type":"code_inline"}]},{"text":" 且哨兵值正确才算完成(AI 公式例外:异步计算,改用 ","type":"text"},{"text":"+formula-verify --ai-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" 对整个写入区间做一次异步状态检查,不用 ","type":"text"},{"text":"+cells-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" 轮询结果,","type":"text"},{"text":"failed","type":"text","marks":[{"type":"code_inline"}]},{"text":" 清零后即使仍有 pending 也可交付并说明);试错 3 次仍失败可降级静态值,交付说明写明「静态值 + 失败原因 + 不随源数据更新」。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"完整继承样式","type":"text","marks":[{"type":"strong"}]},{"text":":新增行列时禁止只读值只写值——原表字体、对齐、底色(含奇偶行交替)、四边框都延续到新区域。","type":"text"},{"text":"物理插入行 / 列","type":"text","marks":[{"type":"strong"}]},{"text":"用 ","type":"text"},{"text":"+dim-insert --inherit-style before|after","type":"text","marks":[{"type":"code_inline"}]},{"text":"(原生继承,比补刷可靠);","type":"text"},{"text":"往已有空白区域扩写","type":"text","marks":[{"type":"strong"}]},{"text":"(如在数据右侧加新列)用 ","type":"text"},{"text":"+range-copy --paste-type formats","type":"text","marks":[{"type":"code_inline"}]},{"text":" 先铺样式再写值;两者都表达不了的非规则样式,才用 ","type":"text"},{"text":"+cells-get --include style","type":"text","marks":[{"type":"code_inline"}]},{"text":" 读源区样式随值写回。无论走哪条路径,插入后都另查行高列宽(行高不随样式继承,插行填长文本前补 ","type":"text"},{"text":"+rows-resize","type":"text","marks":[{"type":"code_inline"}]},{"text":")、合并与跨列标题并补齐。详见 ","type":"text"},{"text":"references/lark-sheets-write-cells.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"原子操作","type":"text","marks":[{"type":"strong"}]},{"text":":排序用 ","type":"text"},{"text":"+range-sort","type":"text","marks":[{"type":"code_inline"}]},{"text":",","type":"text"},{"text":"--range","type":"text","marks":[{"type":"code_inline"}]},{"text":" 覆盖完整记录宽度,排序列只写进 ","type":"text"},{"text":"--sort-keys","type":"text","marks":[{"type":"code_inline"}]},{"text":";删除记录用 ","type":"text"},{"text":"+dim-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":",清空内容 / 格式才用 ","type":"text"},{"text":"+cells-clear","type":"text","marks":[{"type":"code_inline"}]},{"text":";禁止读值后用 ","type":"text"},{"text":"+csv-put","type":"text","marks":[{"type":"code_inline"}]},{"text":" 覆盖来模拟排序 / 删除。仅跨类型且有顺序依赖时才用 high-risk ","type":"text"},{"text":"+batch-update","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"标色分流","type":"text","marks":[{"type":"strong"}]},{"text":":数据变化后应自动重算的高亮 / 标红用条件格式,已确定结果的固定标注用静态样式,装饰性美化按视觉规范。两条路径取色字段用同一判据:用户中文语境下的\"标红 / 染色 / 标记\"指","type":"text"},{"text":"单元格背景色","type":"text","marks":[{"type":"strong"}]},{"text":",\"文字红 / 字体红 / 把字变红\"才用字体色,默认无说明时选背景色。条件格式建完先 ","type":"text"},{"text":"+cond-format-list","type":"text","marks":[{"type":"code_inline"}]},{"text":" 验规则与范围,再 ","type":"text"},{"text":"+cond-format-result-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" 抽查哨兵格命中样式。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"产物可核对","type":"text","marks":[{"type":"strong"}]},{"text":":用户点名的 sheet 名与数量、表头、标题、图例、文件名、口径逐字保留;回复中每项“已完成”都能定位到产物,缺口逐项声明。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"替换与新增","type":"text","marks":[{"type":"strong"}]},{"text":":批量替换 / 删除后搜索确认无残留;新增列要有表头,单位 / 口径另置,不占原表头或数据格。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"不编造","type":"text","marks":[{"type":"strong"}]},{"text":":表外数据须有可核验来源,不用常识或名称推断伪造公司、标准值、行情或法规参数;","type":"text"},{"text":"没有来源就留空","type":"text","marks":[{"type":"strong"}]},{"text":"——凭记忆填的数值大概率与真实值对不上,比留空更糟。留空的格在交付说明里逐项列出格址与缺的来源,不要只写一句\"部分数据缺失\"。","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"🤖 ","type":"text"},{"text":"文本类 NLP 任务首选 AI 公式,别默认退回手工 / Python","type":"text","marks":[{"type":"strong"}]},{"text":":只要对文本列做","type":"text"},{"text":"翻译 / 情感 / 分类打标签 / 信息提取 / 总结 / 润色","type":"text","marks":[{"type":"strong"}]},{"text":"等 NLP,飞书在线表格上优先用原生 ","type":"text"},{"text":"=AI(prompt, range)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 逐列铺开(写法与普通公式一致,见 ","type":"text"},{"text":"references/lark-sheets-formula-translation.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"),一次落表随行自动计算,比逐条读 → 手工判断 → 回写 / Python 调模型再写静态值都更省事。","type":"text"},{"text":"判定标准是「逐行独立」","type":"text","marks":[{"type":"strong"}]},{"text":":每个目标单元格只依赖同一行输入即为逐行独立,","type":"text"},{"text":"数据量(哪怕 1 万 +)、分批、判断复杂度都不改变该判定","type":"text","marks":[{"type":"strong"}]},{"text":"——大数据量下 AI 公式仍是首选,分批只改公式铺设的批次大小(行数很多时按批串行,量级参考每批几百到一千行),不得改为「用 Python 或规则脚本生成语义结果后静态写回」;Python 只能做清洗 / 行号映射 / 构造公式批次,不得读源文本生成目标语义值。只有单个结果依赖多行输入的跨行任务才走非公式路线。AI 公式异步计算,写完先对种子格 / 首格做","type":"text"},{"text":"一次","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"+cells-get --include formula","type":"text","marks":[{"type":"code_inline"}]},{"text":" 核对文本,随后","type":"text"},{"text":"第一校验入口必须是","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"+formula-verify --ai-only --range \u003c整个写入区间>","type":"text","marks":[{"type":"code_inline"}]},{"text":",禁止用 ","type":"text"},{"text":"+cells-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" 轮询计算结果;判据为 ","type":"text"},{"text":"ai_formula_failed_count == 0","type":"text","marks":[{"type":"code_inline"}]},{"text":"(","type":"text"},{"text":"--range","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只透传给后端、不保证收窄汇总口径,按返回的单元格定位核对本次区间,别拿总数对预期条数),满足后即使仍有 pending 也可交付,并告知用户\"AI 公式仍在后台运行\"。","type":"text"}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"流程:了解结构 →(未点名时先按 visible_grid selection 定位)→ 读数据 → 原生工具写入 → 按用户点名项回读验证 → 在线交付。整理 / 美化 / 加汇总行这类会改变表长或版式的任务,收尾把表头行冻住(原表已有冻结设置的不动)。xlsx 验收只在处理本地 xlsx、或用户点名要本地 xlsx / 下载 / 打印时跑。","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"References","type":"text"}]},{"type":"paragraph","content":[{"text":"reference 分两组:先读","type":"text"},{"text":"通用方法与规范","type":"text","marks":[{"type":"strong"}]},{"text":"(横切所有任务的样式 / 公式规则),再按操作对象进入","type":"text"},{"text":"工具参考","type":"text","marks":[{"type":"strong"}]},{"text":"查具体 shortcut。编辑类任务务必先过通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"通用方法与规范(先读,横切所有任务,不含具体 shortcut)","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"描述","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"飞书表格样式与配色规范","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-visual-standards.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框、数字格式等取值标准,以及从零新建表格的版式美化、新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"飞书表格公式生成规则","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-formula-translation.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、数组语义与逐行填充、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。本文负责把公式写对;落表后必须用 ","type":"text"},{"text":"references/lark-sheets-formula-verify.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 对本次公式范围逐段诊断。","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"按对象的工具参考(含 shortcut)","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"描述","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Formula Verify","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-formula-verify.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"公式写入 / 批量填充 / ","type":"text"},{"text":"--copy-to-range","type":"text","marks":[{"type":"code_inline"}]},{"text":" 扩展 / 导入含公式工作簿后的完成检查。普通公式按本次新增或修改范围逐段扫描,合并编译失败与 7 类运行错误;","type":"text"},{"text":"partial","type":"text","marks":[{"type":"code_inline"}]},{"text":" 继续拆分,全部 ","type":"text"},{"text":"status='success'","type":"text","marks":[{"type":"code_inline"}]},{"text":" 后完成。AI 公式用 ","type":"text"},{"text":"--ai-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" 对整个写入区间做一次异步状态检查,pending 可说明后交付。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Workbook","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-workbook.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到\"看看这个表格有什么\"、\"表格结构\"、\"有哪些 sheet\"、\"新建一个 sheet\"、\"删除这个工作表\"、\"重命名\"、\"复制一份\"、\"移动到前面\"时使用。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Sheet Structure","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-sheet-structure.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格的子表结构与布局:查看行高列宽、隐藏、合并、冻结与分组,并执行插入/删除/移动行列等物理结构操作。数据分组统计走 lark-sheets-pivot-table。普通表尾追加优先用 lark-sheets-write-cells 的 ","type":"text"},{"text":"+table-put --mode append","type":"text","marks":[{"type":"code_inline"}]},{"text":" 自动定位末行;只有用户明确要求物理插入行列、继承模板结构或扩容布局时才先用本 reference。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Read Data","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-read-data.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读取飞书表格中的单元格数据。当用户需要\"看看数据\"、\"分析数据\"、\"统计/汇总\"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Search & Replace","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-search-replace.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要\"查找\"、\"搜索\"、\"定位\"某个值,或\"替换\"、\"批量修改文本\"、\"把 A 改成 B\"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如\"汇总金额\"\"统计数量\")当作搜索词。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Write Cells","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-write-cells.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"向飞书表格指定区域批量写入值、公式、样式、批注或单元格图片。纯文本可用 ","type":"text"},{"text":"+csv-put","type":"text","marks":[{"type":"code_inline"}]},{"text":";金额、百分比、日期、布尔、计数和后续参与聚合的列用 ","type":"text"},{"text":"+table-put","type":"text","marks":[{"type":"code_inline"}]},{"text":" 并显式声明 dtypes/formats;公式或富字段用 ","type":"text"},{"text":"+cells-set","type":"text","marks":[{"type":"code_inline"}]},{"text":"。追加数据可直接使用 ","type":"text"},{"text":"+table-put --mode append","type":"text","marks":[{"type":"code_inline"}]},{"text":";只有明确需要物理插行/列时才先走 lark-sheets-sheet-structure。公式落表后必须运行 lark-sheets-formula-verify。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Range Operations","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-range-operations.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式(\"清空\"、\"删除内容\"、\"去掉格式\")、合并/取消合并单元格、调整行高列宽(\"加宽列\"、\"自适应列宽\")、移动/复制/填充/排序数据(\"移动数据\"、\"复制到\"、\"自动填充\"、\"按某列排序\")。写入单元格数据请使用 lark-sheets-write-cells。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Styles Put","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-styles-put.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"把一份声明式视觉规格(样式/边框/合并/行高列宽/冻结)一次性应用到已有飞书表格的多个子表,整份规格一次提交。当任务是对存量表做美化收尾、批量刷样式、统一版式时使用。样式取值标准见 lark-sheets-visual-standards;建新表带样式走 lark-sheets-workbook(+workbook-create --styles)、写数据同步带样式走 lark-sheets-write-cells(+table-put --styles),三者共用同一份 --styles 词汇。仅针对飞书表格。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Batch Update","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-batch-update.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Chart","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-chart.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到\"数据可视化\"、\"画个图\"、\"趋势分析\"、\"对比图\"、\"占比分析\"、\"做个图表\"等数据可视化相关场景。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Pivot Table","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-pivot-table.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到\"分组汇总\"、\"交叉分析\"、\"按XXX统计\"、\"按字段分组\"、\"再分下组\"、\"多维分析\"、\"数据透视\"等场景。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Conditional Format","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-conditional-format.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到\"高亮\"、\"标红\"、\"颜色标记\"、\"数据条\"、\"色阶\"、\"条件样式\"等场景。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Filter","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-filter.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于\"只看\"、\"筛选出\"、\"仅保留符合条件的\"等场景。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Filter View","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-filter-view.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的筛选视图(filter view)。当用户需要\"建一个 XX 视图\"、\"保存这个筛选状态\"、\"切换不同筛选\"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Sparkline","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-sparkline.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于\"趋势线\"、\"单元格内图表\"、\"迷你图\"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Float Image","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-float-image.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于\"插入图片\"、\"添加 logo\"、\"放一张图\"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet History","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-history.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"查询飞书表格的历史版本并回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、或查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lark Sheet Changeset","type":"text","marks":[{"type":"link","attrs":{"href":"references/lark-sheets-changeset.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读取两个版本(CS revision)之间的 changeset(原始变更操作清单),用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20;返回里最外层带当前表格最新版本号。当用户需要\"看看这次改了什么\"、\"核对 AI 改动\"、\"对比两个版本的变更\"时使用。","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"公共 flag 速查","type":"text"}]},{"type":"paragraph","content":[{"text":"各 reference 的 shortcut 标题下用一行徽章标注支持的公共 / 系统 flag(如 ","type":"text"},{"text":"_公共四件套 · 系统:--dry-run_","type":"text","marks":[{"type":"code_inline"}]},{"text":")。type / 必填 / 描述在本段统一声明:","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"公共 flag(定位资源)","type":"text"}]},{"type":"paragraph","content":[{"text":"公共四件套","type":"text","marks":[{"type":"strong"}]},{"text":" = ","type":"text"},{"text":"--url","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--spreadsheet-token","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--sheet-id","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--sheet-name","type":"text","marks":[{"type":"code_inline"}]},{"text":",分成两组 XOR,","type":"text"},{"text":"每组都必须给且只能给一个","type":"text","marks":[{"type":"strong"}]},{"text":"(XOR = 二选一必填,不是\"可选\")——","type":"text"},{"text":"spreadsheet","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指工作簿、","type":"text"},{"text":"sheet","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指子表;条件格式 / 图表 / 筛选视图 / 透视表 / 迷你图 / 浮动图片这类对象在四件套之外另用各自的 ","type":"text"},{"text":"--*-id","type":"text","marks":[{"type":"code_inline"}]},{"text":" 定位:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"spreadsheet 定位(必填)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"--url","type":"text","marks":[{"type":"code_inline"}]},{"text":"(解析 ","type":"text"},{"text":"/sheets/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"/spreadsheets/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"/wiki/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 三种链接;wiki 链接自动定位背后的电子表格)与 ","type":"text"},{"text":"--spreadsheet-token","type":"text","marks":[{"type":"code_inline"}]},{"text":"(裸 token)二选一。","type":"text"},{"text":"例外","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"+workbook-create","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+workbook-import","type":"text","marks":[{"type":"code_inline"}]},{"text":" 产出","type":"text"},{"text":"还不存在","type":"text","marks":[{"type":"strong"}]},{"text":"的表,不接受任何定位 flag。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"sheet 定位(公共四件套 shortcut 必填)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"--sheet-id","type":"text","marks":[{"type":"code_inline"}]},{"text":" 与 ","type":"text"},{"text":"--sheet-name","type":"text","marks":[{"type":"code_inline"}]},{"text":" 二选一。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"不确定 sheet 名时禁止猜 ","type":"text","marks":[{"type":"strong"}]},{"text":"Sheet1","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":":除非对话或上下文已出现具体值,第一步先 ","type":"text"},{"text":"+workbook-info","type":"text","marks":[{"type":"code_inline"}]},{"text":" 拿 ","type":"text"},{"text":"sheets[].sheet_id/title","type":"text","marks":[{"type":"code_inline"}]},{"text":" 再选——中文表的子表常叫\"数据\"/\"工作表 1\"/业务名,猜名大概率撞 ","type":"text"},{"text":"sheet not found","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"--range","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 里的 ","type":"text","marks":[{"type":"strong"}]},{"text":"Sheet1!","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 前缀不能替代 sheet 定位","type":"text","marks":[{"type":"strong"}]},{"text":":仍必须传 ","type":"text"},{"text":"--sheet-id","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--sheet-name","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"A1 引用含 ","type":"text","marks":[{"type":"strong"}]},{"text":"!","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 时整段用单引号包裹","type":"text","marks":[{"type":"strong"}]},{"text":"(","type":"text"},{"text":"--range 'Sheet1!A1:B2'","type":"text","marks":[{"type":"code_inline"}]},{"text":",挡 bash history expansion;别用 ","type":"text"},{"text":"set +H","type":"text","marks":[{"type":"code_inline"}]},{"text":",sh/dash 下非法)。sheet 名要在 A1 里内层再包单引号时用 ","type":"text"},{"text":"'\\''","type":"text","marks":[{"type":"code_inline"}]},{"text":" 转义。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"例外","type":"text","marks":[{"type":"strong"}]},{"text":":徽章标 ","type":"text"},{"text":"_公共:URL/token(无 sheet 定位)…_","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 shortcut 不接受 sheet 定位——工作簿级(","type":"text"},{"text":"+workbook-info","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+sheet-list","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+sheet-create","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+revision-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+changeset-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+history-list|revert|revert-status","type":"text","marks":[{"type":"code_inline"}]},{"text":")、批量与整表级(","type":"text"},{"text":"+batch-update","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+batch-chart-create|update","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+cells-batch-clear","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+styles-put","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+dropdown-update|delete","type":"text","marks":[{"type":"code_inline"}]},{"text":"),以及子表名写在 payload 里的 ","type":"text"},{"text":"+table-put","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"},{"text":"+workbook-export","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只接 ","type":"text"},{"text":"--sheet-id","type":"text","marks":[{"type":"code_inline"}]},{"text":"(无 ","type":"text"},{"text":"--sheet-name","type":"text","marks":[{"type":"code_inline"}]},{"text":"),","type":"text"},{"text":"+pivot-create","type":"text","marks":[{"type":"code_inline"}]},{"text":" 用 ","type":"text"},{"text":"--target-sheet-id/name","type":"text","marks":[{"type":"code_inline"}]},{"text":"(XOR,可都不传)。徽章是判据,本行只是速记。","type":"text"}]}]}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 统一调用范式:两组定位缺一不可(占位符别原样填;表名先 +workbook-info 查)\nlark-cli sheets +csv-get --url \"https://.../sheets/shtXXX\" --sheet-name \"\u003c真实表名>\" --range \"A1:F30\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"系统 flag","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"必填","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"说明","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--dry-run","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"否","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"零副作用:仅打印请求路径与参数模板,不发起调用","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"是(仅 ","type":"text"},{"text":"high-risk-write","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"二次确认;不带时退出码 10。详见 ","type":"text"},{"text":"../lark-shared/SKILL.md","type":"text","marks":[{"type":"link","attrs":{"href":"../lark-shared/SKILL.md","title":null}},{"type":"code_inline"}]},{"text":" 高风险审批协议","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--print-schema","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"否","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"写复合 JSON flag 前结构不确定就先跑它:本地打印 Schema 并退出(不发起调用、不需要其它 required flag),搭配 ","type":"text"},{"text":"--flag-name","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定查哪个 flag,省略时列出该 shortcut 可查的 flag。只有含复合 JSON flag 的 shortcut 支持。","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--flag-name","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"否","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"配合 ","type":"text"},{"text":"--print-schema","type":"text","marks":[{"type":"code_inline"}]},{"text":":flag 名不带 ","type":"text"},{"text":"--","type":"text","marks":[{"type":"code_inline"}]},{"text":" 前缀(","type":"text"},{"text":"cells","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"properties","type":"text","marks":[{"type":"code_inline"}]},{"text":")。","type":"text"},{"text":"支持点分路径切片","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"--flag-name properties.snapshot.plotArea.axes","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只打印该子树,大 schema(chart 的 properties 约 1700 行)按需取,别整篇翻页。","type":"text"}]}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"bool flag 语法","type":"text","marks":[{"type":"strong"}]},{"text":":开启可用裸 ","type":"text"},{"text":"--flag","type":"text","marks":[{"type":"code_inline"}]},{"text":";显式值只用 ","type":"text"},{"text":"--flag=true","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":"--flag=false","type":"text","marks":[{"type":"code_inline"}]},{"text":",不得用空格分隔。","type":"text"}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"high-risk-write 命令清单(exit 10 强确认门禁)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"+batch-update","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+cells-clear","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+cells-batch-clear","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+sheet-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+dim-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+dropdown-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"+history-revert","type":"text","marks":[{"type":"code_inline"}]},{"text":"(整表回滚到历史版本),以及各对象删除 ","type":"text"},{"text":"+chart-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+pivot-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+cond-format-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+filter-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+filter-view-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+sparkline-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+float-image-delete","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]},{"type":"paragraph","content":[{"text":"审批协议","type":"text","marks":[{"type":"strong"}]},{"text":":先 ","type":"text"},{"text":"--dry-run","type":"text","marks":[{"type":"code_inline"}]},{"text":" 预览、向用户展示将执行的操作与影响范围,","type":"text"},{"text":"获得用户明确同意后","type":"text","marks":[{"type":"strong"}]},{"text":"再在原命令追加 ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" 执行。未经用户同意不得带 ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":",也不得在 exit 10 后静默补 ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" 重试——那等于禁用门禁。完整协议见 ","type":"text"},{"text":"../lark-shared/SKILL.md","type":"text","marks":[{"type":"link","attrs":{"href":"../lark-shared/SKILL.md","title":null}},{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"paragraph","content":[{"text":"Schema 的边界","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"--print-schema","type":"text","marks":[{"type":"code_inline"}]},{"text":" 打印的是 flag 值的内部结构,flag 描述要求外层信封时(如 ","type":"text"},{"text":"--sheets","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 ","type":"text"},{"text":"{\"sheets\":[…]}","type":"text","marks":[{"type":"code_inline"}]},{"text":")schema 里看不到那层,按描述补上;reference 的 ","type":"text"},{"text":"## Schemas","type":"text","marks":[{"type":"code_inline"}]},{"text":" 段也只给一层。图表直接 ","type":"text"},{"text":"+chart-create --print-example \u003ctype>","type":"text","marks":[{"type":"code_inline"}]},{"text":" 拿最小可用模板改参。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"flag 内容类型与输出约定(术语速记)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"JSON 类入参分三类:","type":"text"},{"text":"复合 JSON","type":"text","marks":[{"type":"strong"}]},{"text":" = 深层嵌套对象(","type":"text"},{"text":"--print-schema","type":"text","marks":[{"type":"code_inline"}]},{"text":" 可查);","type":"text"},{"text":"简单 JSON","type":"text","marks":[{"type":"strong"}]},{"text":" = 一二维标量数组;","type":"text"},{"text":"非 JSON 文本","type":"text","marks":[{"type":"strong"}]},{"text":" = 原样文本(如 CSV)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"envelope","type":"text","marks":[{"type":"strong"}]},{"text":":所有 shortcut 返回统一外层 ","type":"text"},{"text":"{ok, identity, data, ...}","type":"text","marks":[{"type":"code_inline"}]},{"text":";写操作不会自动回读,校验自行调用 ","type":"text"},{"text":"+*-list","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+*-get","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"+cells-get","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"大 payload 走文件 / stdin,不在命令行内联","type":"text","marks":[{"type":"strong"}]},{"text":":Type 标 ","type":"text"},{"text":"File + Stdin","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 flag 支持 ","type":"text"},{"text":"--flag \"@./x.json\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"(","type":"text"},{"text":"@file","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只接受 cwd 下相对路径,绝对路径被拒)与 ","type":"text"},{"text":"--flag -","type":"text","marks":[{"type":"code_inline"}]},{"text":"(stdin);payload 含换行 / 引号或体量大时一律落文件。","type":"text"},{"text":"stdin 每次调用只能给一个 flag","type":"text","marks":[{"type":"strong"}]},{"text":"——","type":"text"},{"text":"+table-put","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 ","type":"text"},{"text":"--sheets","type":"text","marks":[{"type":"code_inline"}]},{"text":" 与 ","type":"text"},{"text":"--styles","type":"text","marks":[{"type":"code_inline"}]},{"text":" 都是大 JSON 时,一个走 ","type":"text"},{"text":"-","type":"text","marks":[{"type":"code_inline"}]},{"text":"、另一个走 ","type":"text"},{"text":"@./x.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"。临时文件不要落进用户项目目录。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"非 POSIX shell(PowerShell / cmd.exe)适配","type":"text","marks":[{"type":"strong"}]},{"text":":本 skill 全部 ","type":"text"},{"text":"bash","type":"text","marks":[{"type":"code_inline"}]},{"text":" 代码块(heredoc ","type":"text"},{"text":"\u003c\u003c'JSON'","type":"text","marks":[{"type":"code_inline"}]},{"text":"、单引号转义 ","type":"text"},{"text":"'\\''","type":"text","marks":[{"type":"code_inline"}]},{"text":")只适用于 bash / zsh,动手前先判断当前 shell,非 POSIX 环境按下表改写,","type":"text"},{"text":"不要试错式改引号","type":"text","marks":[{"type":"strong"}]},{"text":"——","type":"text"},{"text":"@file","type":"text","marks":[{"type":"code_inline"}]},{"text":"(cwd 相对路径)是全平台无引号问题的兜底形态:","type":"text"}]}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"形态","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash / zsh","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PowerShell","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cmd.exe","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"大 / 多行 JSON","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--flag - \u003c\u003c'JSON' … JSON","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"先写 UTF-8 无 BOM 文件再 ","type":"text"},{"text":"--flag '@./x.json'","type":"text","marks":[{"type":"code_inline"}]},{"text":",或 ","type":"text"},{"text":"Get-Content -Raw ./x.json | lark-cli … --flag -","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"先写文件再 ","type":"text"},{"text":"--flag @./x.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"(cmd 无 heredoc / 管道读文件不可靠)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"单行 inline JSON","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--flag '{\"a\":1}'","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--flag '{\"a\":1}'","type":"text","marks":[{"type":"code_inline"}]},{"text":"(PS 单引号同为字面量)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"不要 inline——cmd 会吃掉内层双引号,一律走 ","type":"text"},{"text":"@file","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]}]},"metadata":{"date":"2026-11-02","name":"lark-sheets","author":"@skillopedia","source":{"stars":17934,"repo_name":"cli","origin_url":"https://github.com/larksuite/cli/blob/HEAD/skills/lark-sheets/SKILL.md","repo_owner":"larksuite","body_sha256":"dfbf915c719cf45bc599a38986e856cb07199c7b1a35c6d9cf26ee55aa3cf843","cluster_key":"64ead965980ddf6bd956e2028a641f174388e5e512cb6ed9465c164433621664","clean_bundle":{"format":"clean-skill-bundle-v1","source":"larksuite/cli/skills/lark-sheets/SKILL.md","text_bytes":542790,"source_commit":"f31194b461afbd75479d12850b0f9cb9427cb762","skills_sh_rank":135,"attachment_count":29,"text_attachments":29,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/lark-sheets/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"3.5.2","category":"integrations-apis","category_label":"Integrations"},"exact_dupes_collapsed_into_this":0},"version":"3.5.2","category":"integrations-apis","metadata":{"cliHelp":"lark-cli sheets --help","requires":{"bins":["lark-cli"],"siblings":["lark-shared"]}},"import_tag":"skills-sh-top20k-2026-11","description":"飞书电子表格:创建和操作电子表格。支持工作表与行列结构(增删/合并/尺寸/隐藏/冻结/分组)、单元格读写(值/公式/样式/批注/单元格图片)、区域复制移动排序填充、查找替换、批量更新,图表、透视表、条件格式、筛选器与筛选视图、下拉列表、迷你图、浮动图片等对象的创建与维护,以及公式校验、历史版本回滚、本地 Excel/CSV 与飞书表格的导入导出。当用户需要创建或编辑表格、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)时使用。多维表格(Base/bitable)请改用 lark-base;若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"}},"renderedAt":1793666080900}

sheets CRITICAL — 开始前 MUST 先用 Read 工具读取 ,其中包含认证、权限处理。 场景 → 命令速查 按当前动作选行;下一步必须 Read 该行 reference,读取完成前不得执行命令。只读命中的文档;含公式 / 样式等横切动作时再读对应规范,禁止用目录枚举代替 Read。 | 你要做的事 | ✅ 正确写法 | 动手前读(先 Read 再动手) | | --- | --- | --- | | 读数据 | (纯值/CSV)、 (公式/样式/批注) | 读 | | 写入数据 | (无类型歧义纯文本)、 (typed;量值/真日期;标签/编号/前导零/文本数字用 object,禁裸 csv-put)、 (公式/富写入)、 (样式)、 (单元格图片) | 读 | | 格式继承(新列/新行) | 物理插行 / 插列用 ;往已有空白区域扩写用 先铺样式再写值 | 读 ;插行插列再读 | | 工作簿操作 | 、 、 、 、 、 | 读 | | 行列操作 | 排序用 原子移动整行;合并 / 取消合并用 / ;清空内容才用 ;尺寸用 / | 读 ;涉结构布局再读 | | 美化收尾 | | 读 | | 子表结构 | 、 ;删整行 / 列用 ,不能用 clear 代替 | 读 | | 画图表 / 可视化 / 柱状图 / 折线图 / 饼图 / 趋势 / 占比 | 单图用 ,…