@amaster.ai/pi-lark 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/package.json +2 -2
  2. package/skills/lark-apps/SKILL.md +39 -6
  3. package/skills/lark-apps/references/lark-apps-cloud-dev.md +5 -4
  4. package/skills/lark-apps/references/lark-apps-create.md +6 -3
  5. package/skills/lark-apps/references/lark-apps-get.md +1 -1
  6. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  7. package/skills/lark-apps/references/lark-apps-local-dev.md +27 -1
  8. package/skills/lark-apps/references/lark-apps-release-create.md +1 -1
  9. package/skills/lark-base/SKILL.md +14 -6
  10. package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +17 -1
  11. package/skills/lark-base/references/lark-base-dashboard.md +17 -4
  12. package/skills/lark-base/references/lark-base-data-query-guide.md +8 -0
  13. package/skills/lark-base/references/lark-base-field-create.md +19 -8
  14. package/skills/lark-base/references/lark-base-field-json.md +5 -2
  15. package/skills/lark-calendar/SKILL.md +1 -1
  16. package/skills/lark-doc/SKILL.md +26 -61
  17. package/skills/lark-doc/references/genres/business-analysis.md +30 -0
  18. package/skills/lark-doc/references/genres/data-report.md +32 -0
  19. package/skills/lark-doc/references/genres/email.md +38 -0
  20. package/skills/lark-doc/references/genres/execution-plan.md +27 -0
  21. package/skills/lark-doc/references/genres/formal-doc.md +37 -0
  22. package/skills/lark-doc/references/genres/meeting-minutes.md +24 -0
  23. package/skills/lark-doc/references/genres/memo-brief.md +25 -0
  24. package/skills/lark-doc/references/genres/official-redhead.md +73 -0
  25. package/skills/lark-doc/references/genres/prd.md +26 -0
  26. package/skills/lark-doc/references/genres/proposal.md +24 -0
  27. package/skills/lark-doc/references/genres/research-report.md +32 -0
  28. package/skills/lark-doc/references/genres/retrospective.md +25 -0
  29. package/skills/lark-doc/references/genres/route-consumer.md +37 -0
  30. package/skills/lark-doc/references/genres/route-creative.md +36 -0
  31. package/skills/lark-doc/references/genres/route-knowledge.md +39 -0
  32. package/skills/lark-doc/references/genres/route-marketing.md +40 -0
  33. package/skills/lark-doc/references/genres/route-media.md +36 -0
  34. package/skills/lark-doc/references/genres/route-opinion.md +38 -0
  35. package/skills/lark-doc/references/genres/route-personal-brand.md +36 -0
  36. package/skills/lark-doc/references/genres/route-platform.md +9 -0
  37. package/skills/lark-doc/references/genres/route-report.md +10 -0
  38. package/skills/lark-doc/references/genres/route-workplace.md +17 -0
  39. package/skills/lark-doc/references/genres/sop-tutorial.md +41 -0
  40. package/skills/lark-doc/references/genres/technical-doc.md +39 -0
  41. package/skills/lark-doc/references/genres/wechat.md +39 -0
  42. package/skills/lark-doc/references/genres/weekly-report.md +24 -0
  43. package/skills/lark-doc/references/genres/white-paper.md +32 -0
  44. package/skills/lark-doc/references/genres/xiaohongshu.md +38 -0
  45. package/skills/lark-doc/references/lark-doc-create-workflow.md +121 -0
  46. package/skills/lark-doc/references/lark-doc-create.md +22 -48
  47. package/skills/lark-doc/references/lark-doc-fetch.md +75 -92
  48. package/skills/lark-doc/references/lark-doc-history.md +16 -15
  49. package/skills/lark-doc/references/lark-doc-md.md +5 -1
  50. package/skills/lark-doc/references/lark-doc-media-download.md +2 -1
  51. package/skills/lark-doc/references/lark-doc-script.md +76 -0
  52. package/skills/lark-doc/references/lark-doc-update.md +70 -222
  53. package/skills/lark-doc/references/lark-doc-whiteboard.md +5 -9
  54. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +17 -12
  55. package/skills/lark-doc/references/lark-doc-xml.md +38 -167
  56. package/skills/lark-drive/SKILL.md +7 -5
  57. package/skills/lark-drive/references/lark-drive-apply-permission.md +1 -1
  58. package/skills/lark-drive/references/lark-drive-copy.md +87 -0
  59. package/skills/lark-drive/references/lark-drive-download.md +2 -1
  60. package/skills/lark-drive/references/lark-drive-export.md +3 -0
  61. package/skills/lark-drive/references/lark-drive-task-result.md +3 -0
  62. package/skills/lark-drive/references/lark-drive-update-title.md +78 -0
  63. package/skills/lark-event/SKILL.md +7 -4
  64. package/skills/lark-event/references/lark-event-vc.md +8 -2
  65. package/skills/lark-im/SKILL.md +8 -8
  66. package/skills/lark-im/references/lark-im-chat-list.md +9 -2
  67. package/skills/lark-im/references/lark-im-chat-members-list.md +7 -4
  68. package/skills/lark-im/references/lark-im-chat-messages-list.md +10 -3
  69. package/skills/lark-im/references/lark-im-chat-search.md +9 -2
  70. package/skills/lark-im/references/lark-im-feed-group-list-item.md +2 -2
  71. package/skills/lark-im/references/lark-im-feed-group-list.md +2 -2
  72. package/skills/lark-im/references/lark-im-feed-shortcut-list.md +1 -1
  73. package/skills/lark-im/references/lark-im-flag-list.md +2 -2
  74. package/skills/lark-im/references/lark-im-message-enrichment.md +1 -1
  75. package/skills/lark-im/references/lark-im-messages-resources-download.md +19 -25
  76. package/skills/lark-im/references/lark-im-messages-search.md +4 -5
  77. package/skills/lark-im/references/lark-im-threads-messages-list.md +8 -4
  78. package/skills/lark-mail/references/lark-mail-triage.md +19 -4
  79. package/skills/lark-minutes/SKILL.md +1 -1
  80. package/skills/lark-minutes/references/lark-minutes-search.md +6 -7
  81. package/skills/lark-shared/SKILL.md +3 -3
  82. package/skills/lark-sheets/SKILL.md +83 -82
  83. package/skills/lark-sheets/references/lark-sheets-batch-update.md +13 -58
  84. package/skills/lark-sheets/references/lark-sheets-chart.md +2 -1
  85. package/skills/lark-sheets/references/lark-sheets-conditional-format.md +1 -1
  86. package/skills/lark-sheets/references/lark-sheets-range-operations.md +5 -5
  87. package/skills/lark-sheets/references/lark-sheets-read-data.md +80 -6
  88. package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +21 -10
  89. package/skills/lark-sheets/references/lark-sheets-styles-put.md +93 -0
  90. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +2 -2
  91. package/skills/lark-sheets/references/lark-sheets-workbook.md +4 -3
  92. package/skills/lark-sheets/references/lark-sheets-write-cells.md +40 -12
  93. package/skills/lark-sheets/scripts/lark_detect_subtables.py +593 -0
  94. package/skills/lark-sheets/scripts/lark_inspect_workbook.py +188 -0
  95. package/skills/lark-sheets/scripts/lark_profile_table.py +614 -0
  96. package/skills/lark-sheets/scripts/lark_sheet_range.py +176 -0
  97. package/skills/lark-sheets/scripts/lark_sheet_read_cli.py +184 -0
  98. package/skills/lark-sheets/scripts/sheets_df.py +21 -3
  99. package/skills/lark-slides/SKILL.md +27 -44
  100. package/skills/lark-slides/references/lark-slides-add-slide.md +92 -0
  101. package/skills/lark-slides/references/lark-slides-create.md +77 -65
  102. package/skills/lark-slides/references/lark-slides-delete-slide.md +65 -0
  103. package/skills/lark-slides/references/lark-slides-edit-workflows.md +6 -7
  104. package/skills/lark-slides/references/lark-slides-media-upload.md +3 -25
  105. package/skills/lark-slides/references/lark-slides-replace-slide.md +22 -1
  106. package/skills/lark-slides/references/lark-slides-screenshot.md +31 -13
  107. package/skills/lark-slides/references/lark-slides-update-slide.md +146 -0
  108. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +31 -8
  109. package/skills/lark-slides/references/slides_chart_demo.xml +1 -2
  110. package/skills/lark-slides/references/slides_xml_schema_definition.xml +48 -4
  111. package/skills/lark-slides/references/troubleshooting.md +7 -8
  112. package/skills/lark-slides/references/validation-checklist.md +4 -4
  113. package/skills/lark-slides/references/xml-schema-quick-ref.md +23 -11
  114. package/skills/lark-slides/scripts/sxsd_validator.py +154 -10
  115. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +360 -76
  116. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +1138 -214
  117. package/skills/lark-whiteboard/SKILL.md +15 -8
  118. package/skills/lark-whiteboard/references/lark-whiteboard-export.md +4 -3
  119. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +4 -4
  120. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +19 -17
  121. package/skills/lark-whiteboard/routes/dsl.md +8 -2
  122. package/skills/lark-whiteboard/routes/mermaid.md +1 -1
  123. package/skills/lark-whiteboard/routes/svg-edit.md +5 -2
  124. package/skills/lark-whiteboard/routes/svg.md +3 -1
  125. package/skills/lark-whiteboard/scenes/mention.md +71 -0
  126. package/skills/lark-wiki/SKILL.md +5 -3
  127. package/skills/lark-wiki/references/lark-wiki-delete-space.md +6 -3
  128. package/skills/lark-doc/references/lark-doc-word-stat.md +0 -93
  129. package/skills/lark-doc/references/style/lark-doc-create-workflow.md +0 -47
  130. package/skills/lark-doc/references/style/lark-doc-style.md +0 -68
  131. package/skills/lark-doc/references/style/lark-doc-update-workflow.md +0 -48
  132. package/skills/lark-doc/scripts/doc_word_stat.py +0 -1243
  133. package/skills/lark-slides/references/lark-slides-replace-pages.md +0 -95
  134. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +0 -219
  135. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +0 -126
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
+ # SPDX-License-Identifier: MIT
4
+ """Profile a candidate table range in a Lark sheet."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import re
10
+ from typing import Any
11
+
12
+ from lark_sheet_range import col_to_index, format_range, index_to_col, parse_range
13
+ from lark_sheet_read_cli import (
14
+ LarkCliError,
15
+ add_spreadsheet_args,
16
+ emit_error,
17
+ emit_success,
18
+ envelope_data,
19
+ run_sheets,
20
+ )
21
+ from lark_detect_subtables import CsvGrid, parse_annotated_csv
22
+
23
+ ACTION = "profile_table"
24
+ ERROR_VALUES = ("#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A")
25
+ TOTAL_KEYWORDS = ("合计", "总计", "小计", "汇总", "累计")
26
+ TOTAL_EN_RE = re.compile(r"\b(?:grand total|subtotal|total)\b", re.IGNORECASE)
27
+ SIGNATURE_KEYWORDS = ("编制人", "审核人", "审批人", "负责人", "经理", "签名")
28
+
29
+
30
+ def _is_number(value: str) -> bool:
31
+ text = value.strip().replace(",", "")
32
+ if text.endswith("%"):
33
+ text = text[:-1]
34
+ try:
35
+ float(text)
36
+ return True
37
+ except ValueError:
38
+ return False
39
+
40
+
41
+ def _is_date_like(value: str) -> bool:
42
+ text = value.strip()
43
+ if re.match(r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(:\d{2})?)?$", text):
44
+ return True
45
+ if re.match(r"^\d{8}$", text):
46
+ year = int(text[:4])
47
+ month = int(text[4:6])
48
+ day = int(text[6:8])
49
+ return 1900 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31
50
+ if re.match(r"^\d{6}$", text):
51
+ year = int(text[:4])
52
+ month = int(text[4:6])
53
+ return 1900 <= year <= 2100 and 1 <= month <= 12
54
+ return False
55
+
56
+
57
+ def _classify_value(value: str) -> str:
58
+ text = value.strip()
59
+ if not text:
60
+ return "empty"
61
+ if any(err in text for err in ERROR_VALUES):
62
+ return "error"
63
+ if re.match(r"^0\d+$", text) or re.match(r"^\d{11,}$", text):
64
+ return "string_id"
65
+ if _is_date_like(text):
66
+ return "date"
67
+ if _is_number(text):
68
+ return "number"
69
+ if text.lower() in {"true", "false", "yes", "no"}:
70
+ return "bool"
71
+ return "string"
72
+
73
+
74
+ def _row_values(grid: CsvGrid, row_index: int) -> list[str]:
75
+ return grid.values[row_index]
76
+
77
+
78
+ def _header_score(row: list[str]) -> tuple[int, int]:
79
+ non_empty = [value for value in row if value.strip()]
80
+ if not non_empty:
81
+ return 0, 0
82
+ strings = sum(1 for value in non_empty if _classify_value(value) in {"string", "string_id"})
83
+ data_like = sum(
84
+ 1 for value in non_empty if _classify_value(value) in {"number", "date", "bool"}
85
+ )
86
+ return strings * 2 + len(non_empty) - data_like * 3, len(non_empty)
87
+
88
+
89
+ def _header_candidate_score(grid: CsvGrid, index: int) -> int:
90
+ score, _ = _header_score(grid.values[index])
91
+ following = grid.values[index + 1 : index + 4]
92
+ following_non_empty = [value for row in following for value in row if value.strip()]
93
+ following_data_like = sum(
94
+ 1 for value in following_non_empty if _classify_value(value) in {"number", "date", "bool"}
95
+ )
96
+ # A schema row is usually followed by data-shaped values; use this as a
97
+ # small tie-breaker while preferring earlier candidates with equal evidence.
98
+ score += min(3, following_data_like)
99
+ return score - index // 2
100
+
101
+
102
+ def detect_header_row(grid: CsvGrid, scan_rows: int = 20) -> int | None:
103
+ best_row = None
104
+ best_score = None
105
+ for idx, row in enumerate(grid.values[:scan_rows]):
106
+ _, non_empty_count = _header_score(row)
107
+ if not non_empty_count:
108
+ continue
109
+ score = _header_candidate_score(grid, idx)
110
+ if best_score is None or score > best_score:
111
+ best_score = score
112
+ best_row = grid.row_numbers[idx]
113
+ return best_row
114
+
115
+
116
+ def _possible_multi_row_header(grid: CsvGrid, header_row: int | None) -> bool:
117
+ if header_row is None or header_row not in grid.row_numbers:
118
+ return False
119
+ header_index = grid.row_numbers.index(header_row)
120
+ if header_index + 1 >= len(grid.values):
121
+ return False
122
+ header_score, header_non_empty = _header_score(grid.values[header_index])
123
+ next_score, next_non_empty = _header_score(grid.values[header_index + 1])
124
+ if header_non_empty < 2 or next_non_empty < 2:
125
+ return False
126
+ return next_score >= max(4, (header_score * 3 + 3) // 4)
127
+
128
+
129
+ def _header_row_not_first(grid: CsvGrid, header_row: int | None) -> bool:
130
+ if header_row is None:
131
+ return False
132
+ first_non_empty = next(
133
+ (
134
+ row_num
135
+ for row_num, row in zip(grid.row_numbers, grid.values)
136
+ if any(value.strip() for value in row)
137
+ ),
138
+ None,
139
+ )
140
+ return first_non_empty is not None and first_non_empty != header_row
141
+
142
+
143
+ def detect_special_rows(grid: CsvGrid, header_row: int | None) -> list[dict[str, Any]]:
144
+ specials = []
145
+ for idx, row in enumerate(grid.values):
146
+ row_num = grid.row_numbers[idx]
147
+ if header_row is not None and row_num <= header_row:
148
+ continue
149
+ joined = " ".join(value.strip() for value in row if value.strip())
150
+ if not joined:
151
+ specials.append({"row": row_num, "reason": "empty_row", "sample": ""})
152
+ continue
153
+ if any(keyword in joined for keyword in TOTAL_KEYWORDS) or TOTAL_EN_RE.search(joined):
154
+ specials.append({"row": row_num, "reason": "total_row", "sample": joined[:120]})
155
+ elif any(keyword in joined for keyword in SIGNATURE_KEYWORDS):
156
+ specials.append({"row": row_num, "reason": "signature_row", "sample": joined[:120]})
157
+ return specials
158
+
159
+
160
+ def _last_data_row(grid: CsvGrid, header_row: int | None, specials: list[dict[str, Any]]) -> int | None:
161
+ special_rows = {item["row"] for item in specials if item["reason"] != "empty_row"}
162
+ for idx in range(len(grid.row_numbers) - 1, -1, -1):
163
+ row_num = grid.row_numbers[idx]
164
+ if header_row is not None and row_num <= header_row:
165
+ continue
166
+ if row_num in special_rows:
167
+ continue
168
+ if any(value.strip() for value in grid.values[idx]):
169
+ return row_num
170
+ return None
171
+
172
+
173
+ def _column_values(grid: CsvGrid, col_index: int, start_row: int, end_row: int | None) -> list[str]:
174
+ values = []
175
+ for idx, row_num in enumerate(grid.row_numbers):
176
+ if row_num < start_row:
177
+ continue
178
+ if end_row is not None and row_num > end_row:
179
+ continue
180
+ row = grid.values[idx]
181
+ values.append(row[col_index] if col_index < len(row) else "")
182
+ return values
183
+
184
+
185
+ def _type_guess(type_counts: dict[str, int]) -> str:
186
+ non_empty_counts = {key: value for key, value in type_counts.items() if key != "empty" and value}
187
+ if not non_empty_counts:
188
+ return "empty"
189
+ if len(non_empty_counts) == 1:
190
+ return next(iter(non_empty_counts))
191
+ if "error" in non_empty_counts:
192
+ return "mixed_with_errors"
193
+ if "string_id" in non_empty_counts and set(non_empty_counts) <= {"string_id", "string"}:
194
+ return "string_id"
195
+ return "mixed"
196
+
197
+
198
+ def profile_columns(
199
+ grid: CsvGrid,
200
+ *,
201
+ header_row: int | None,
202
+ data_start_row: int,
203
+ data_end_row: int | None,
204
+ ) -> list[dict[str, Any]]:
205
+ header_values = []
206
+ if header_row in grid.row_numbers:
207
+ header_values = _row_values(grid, grid.row_numbers.index(header_row))
208
+
209
+ profiles = []
210
+ for idx, col in enumerate(grid.col_letters):
211
+ name = ""
212
+ if idx < len(header_values):
213
+ name = header_values[idx].strip()
214
+ if not name:
215
+ name = f"unnamed_{col}"
216
+
217
+ values = _column_values(grid, idx, data_start_row, data_end_row)
218
+ type_counts: dict[str, int] = {}
219
+ examples = []
220
+ error_count = 0
221
+ for value in values:
222
+ value_type = _classify_value(value)
223
+ type_counts[value_type] = type_counts.get(value_type, 0) + 1
224
+ if value_type == "error":
225
+ error_count += 1
226
+ if value.strip() and len(examples) < 5:
227
+ examples.append(value)
228
+ non_empty = len(values) - type_counts.get("empty", 0)
229
+ warnings = []
230
+ if type_counts.get("string_id"):
231
+ warnings.append("long_numeric_like_id")
232
+ if error_count:
233
+ warnings.append("formula_or_value_errors")
234
+ if non_empty and type_counts.get("empty", 0) / len(values) > 0.5:
235
+ warnings.append("many_empty_cells")
236
+ if _type_guess(type_counts) == "mixed":
237
+ warnings.append("mixed_value_types")
238
+
239
+ profiles.append(
240
+ {
241
+ "name": name,
242
+ "col": col,
243
+ "non_empty": non_empty,
244
+ "empty": type_counts.get("empty", 0),
245
+ "type_guess": _type_guess(type_counts),
246
+ "type_distribution": type_counts,
247
+ "examples": examples,
248
+ "warnings": warnings,
249
+ }
250
+ )
251
+ return profiles
252
+
253
+
254
+ def _field_map(columns: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
255
+ result: dict[str, list[dict[str, Any]]] = {}
256
+ for column in columns:
257
+ name = column["name"]
258
+ result.setdefault(name, []).append(
259
+ {
260
+ "col": column["col"],
261
+ "type_guess": column["type_guess"],
262
+ "warnings": column["warnings"],
263
+ }
264
+ )
265
+ return result
266
+
267
+
268
+ def _risk_warnings(
269
+ *,
270
+ columns: list[dict[str, Any]],
271
+ special_rows: list[dict[str, Any]],
272
+ header_row: int | None,
273
+ data_range: str | None,
274
+ possible_multi_row_header: bool,
275
+ header_row_not_first: bool,
276
+ data_range_has_gaps: bool,
277
+ data_range_has_col_gaps: bool,
278
+ hidden_rows: list[int],
279
+ hidden_columns: list[str],
280
+ ) -> list[str]:
281
+ warnings = {warning for column in columns for warning in column["warnings"]}
282
+ names = [column["name"] for column in columns]
283
+ if len(names) != len(set(names)):
284
+ warnings.add("duplicate_headers")
285
+ if any(name.startswith("unnamed_") for name in names):
286
+ warnings.add("unnamed_columns")
287
+ if header_row is None:
288
+ warnings.add("header_not_detected")
289
+ if data_range is None:
290
+ warnings.add("data_range_not_detected")
291
+ if any(item["reason"] != "empty_row" for item in special_rows):
292
+ warnings.add("special_rows_present")
293
+ if any(item["reason"] == "empty_row" for item in special_rows):
294
+ warnings.add("empty_rows_present")
295
+ if possible_multi_row_header:
296
+ warnings.add("possible_multi_row_header")
297
+ if header_row_not_first:
298
+ warnings.add("header_row_not_first")
299
+ if data_range_has_gaps:
300
+ warnings.add("data_range_has_gaps")
301
+ if data_range_has_col_gaps:
302
+ warnings.add("data_range_has_col_gaps")
303
+ if hidden_rows:
304
+ warnings.add("hidden_rows_in_range")
305
+ if hidden_columns:
306
+ warnings.add("hidden_columns_in_range")
307
+ return sorted(warnings)
308
+
309
+
310
+ def _col_segments(col_letters: list[str]) -> list[list[str]]:
311
+ """Group column letters into contiguous runs of real column indices.
312
+
313
+ With --skip-hidden the returned columns can be non-consecutive (A, C when B
314
+ is hidden). Reporting a single data_range across that gap would let a caller
315
+ write the hidden-column-free data back as if it were contiguous, shifting
316
+ every value right of the gap.
317
+ """
318
+ if not col_letters:
319
+ return []
320
+ indices = [col_to_index(col) for col in col_letters]
321
+ segments = []
322
+ start = previous = indices[0]
323
+ for current in indices[1:]:
324
+ if current == previous + 1:
325
+ previous = current
326
+ continue
327
+ segments.append([index_to_col(start), index_to_col(previous)])
328
+ start = previous = current
329
+ segments.append([index_to_col(start), index_to_col(previous)])
330
+ return segments
331
+
332
+
333
+ def _row_segments(row_numbers: list[int]) -> list[list[int]]:
334
+ if not row_numbers:
335
+ return []
336
+ segments = []
337
+ start = end = row_numbers[0]
338
+ for row in row_numbers[1:]:
339
+ if row == end + 1:
340
+ end = row
341
+ continue
342
+ segments.append([start, end])
343
+ start = end = row
344
+ segments.append([start, end])
345
+ return segments
346
+
347
+
348
+ def _write_hints(
349
+ grid: CsvGrid,
350
+ *,
351
+ header_row: int | None,
352
+ data_start: int,
353
+ bounds,
354
+ hidden_columns: list[str] | None = None,
355
+ skip_hidden: bool = False,
356
+ ) -> dict[str, Any]:
357
+ last_non_empty_col = None
358
+ for idx, col in enumerate(grid.col_letters):
359
+ if any(idx < len(row) and row[idx].strip() for row in grid.values):
360
+ last_non_empty_col = col_to_index(col)
361
+ safe_col_num = (last_non_empty_col + 1) if last_non_empty_col else bounds.start_col
362
+ # Step over hidden columns. Under --skip-hidden they are absent from the
363
+ # grid entirely, so "one past the last visible column" can land ON a hidden
364
+ # column that holds data — and when the hidden columns sit at the right edge
365
+ # there is no gap in the returned letters either, so data_range_has_col_gaps
366
+ # stays silent too. Appending there would overwrite data nobody can see.
367
+ hidden_indices = {col_to_index(col) for col in (hidden_columns or [])}
368
+ skipped_hidden: list[str] = []
369
+ while safe_col_num in hidden_indices:
370
+ skipped_hidden.append(index_to_col(safe_col_num))
371
+ safe_col_num += 1
372
+ safe_col = index_to_col(safe_col_num)
373
+ hints = {
374
+ "last_non_empty_col": index_to_col(last_non_empty_col) if last_non_empty_col else None,
375
+ }
376
+ if not (skip_hidden and hidden_columns is None):
377
+ hints.update(
378
+ {
379
+ "safe_append_col": safe_col,
380
+ "safe_append_header_cell": f"{safe_col}{header_row}" if header_row else None,
381
+ "safe_append_data_start_cell": f"{safe_col}{data_start}",
382
+ }
383
+ )
384
+ if skipped_hidden:
385
+ hints["skipped_hidden_cols"] = skipped_hidden
386
+ return hints
387
+
388
+
389
+ def profile_grid(
390
+ grid: CsvGrid,
391
+ source_range: str,
392
+ *,
393
+ skip_hidden: bool = False,
394
+ hidden_rows: list[int] | None = None,
395
+ hidden_columns: list[str] | None = None,
396
+ all_hidden_columns: list[str] | None = None,
397
+ header_scan_rows: int = 20,
398
+ ) -> dict[str, Any]:
399
+ max_row = max(grid.row_numbers, default=1)
400
+ max_col = max((col_to_index(col) for col in grid.col_letters), default=1)
401
+ bounds = parse_range(source_range, max_row=max_row, max_col=max_col)
402
+ header_row = detect_header_row(grid, header_scan_rows)
403
+ data_start = (header_row + 1) if header_row else bounds.start_row
404
+ specials = detect_special_rows(grid, header_row)
405
+ data_end = _last_data_row(grid, header_row, specials)
406
+ data_range = None
407
+ if data_end is not None and data_end >= data_start:
408
+ data_range = format_range(data_start, bounds.start_col, data_end, bounds.end_col)
409
+ columns = profile_columns(
410
+ grid,
411
+ header_row=header_row,
412
+ data_start_row=data_start,
413
+ data_end_row=data_end,
414
+ )
415
+ data_row_numbers = []
416
+ if data_end is not None and data_end >= data_start:
417
+ data_row_numbers = [
418
+ row for row in grid.row_numbers if data_start <= row <= data_end
419
+ ]
420
+ data_row_segments = _row_segments(data_row_numbers)
421
+ data_range_has_gaps = bool(data_row_numbers) and (
422
+ data_row_numbers[0] != data_start or len(data_row_segments) > 1
423
+ )
424
+ data_rows = len(data_row_numbers)
425
+ data_col_segments = _col_segments(grid.col_letters)
426
+ data_range_has_col_gaps = len(data_col_segments) > 1
427
+ hidden_rows = hidden_rows or []
428
+ hidden_columns = hidden_columns or []
429
+ possible_multi_row_header = _possible_multi_row_header(grid, header_row)
430
+ header_row_not_first = _header_row_not_first(grid, header_row)
431
+
432
+ return {
433
+ "summary": {
434
+ "range": source_range,
435
+ "header_row": header_row,
436
+ "data_range": data_range,
437
+ "data_rows": data_rows,
438
+ "data_row_segments": data_row_segments,
439
+ "data_col_segments": data_col_segments,
440
+ "column_count": len(columns),
441
+ "special_rows_count": len(specials),
442
+ },
443
+ "range": source_range,
444
+ "header_row": header_row,
445
+ "data_range": data_range,
446
+ "data_row_segments": data_row_segments,
447
+ "data_col_segments": data_col_segments,
448
+ "columns": columns,
449
+ "field_map": _field_map(columns),
450
+ "risk_warnings": _risk_warnings(
451
+ columns=columns,
452
+ special_rows=specials,
453
+ header_row=header_row,
454
+ data_range=data_range,
455
+ possible_multi_row_header=possible_multi_row_header,
456
+ header_row_not_first=header_row_not_first,
457
+ data_range_has_gaps=data_range_has_gaps,
458
+ data_range_has_col_gaps=data_range_has_col_gaps,
459
+ hidden_rows=hidden_rows,
460
+ hidden_columns=hidden_columns,
461
+ ),
462
+ "visibility": {
463
+ "skip_hidden": skip_hidden,
464
+ "hidden_rows_in_range": hidden_rows,
465
+ "hidden_columns_in_range": hidden_columns,
466
+ },
467
+ "write_hints": _write_hints(
468
+ grid,
469
+ header_row=header_row,
470
+ data_start=data_start,
471
+ bounds=bounds,
472
+ hidden_columns=all_hidden_columns,
473
+ skip_hidden=skip_hidden,
474
+ ),
475
+ "special_rows": specials,
476
+ }
477
+
478
+
479
+ def _hidden_rows_and_columns(grid: CsvGrid, layout: dict[str, Any]) -> tuple[list[int], list[str]]:
480
+ def indexes(key: str) -> set[int]:
481
+ values = layout.get(key)
482
+ if not isinstance(values, list):
483
+ return set()
484
+ result = set()
485
+ for value in values:
486
+ try:
487
+ result.add(int(value) + 1) # +sheet-info uses zero-based indices.
488
+ except (TypeError, ValueError):
489
+ continue
490
+ return result
491
+
492
+ hidden_row_indexes = indexes("hidden_rows")
493
+ hidden_columns = layout.get("hidden_cols") or layout.get("hidden_columns") or []
494
+ hidden_column_letters = set()
495
+ for value in hidden_columns if isinstance(hidden_columns, list) else []:
496
+ if isinstance(value, str) and value.isalpha():
497
+ hidden_column_letters.add(value.upper())
498
+ else:
499
+ try:
500
+ hidden_column_letters.add(index_to_col(int(value) + 1))
501
+ except (TypeError, ValueError):
502
+ continue
503
+ rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)
504
+ columns = [col for col in grid.col_letters if col.upper() in hidden_column_letters]
505
+ return rows, columns
506
+
507
+
508
+ def _all_hidden_columns(layout: dict[str, Any]) -> list[str]:
509
+ """Every hidden column on the sheet, not just those inside the grid.
510
+
511
+ _hidden_rows_and_columns intersects with the returned grid, which is the
512
+ right scope for the "there are hidden rows/columns in what you read"
513
+ warnings. The append hint needs the opposite: the columns that are NOT in
514
+ the grid precisely because they are hidden.
515
+ """
516
+ letters: list[str] = []
517
+ raw = layout.get("hidden_cols") or layout.get("hidden_columns") or []
518
+ for value in raw if isinstance(raw, list) else []:
519
+ if isinstance(value, str) and value.isalpha():
520
+ letters.append(value.upper())
521
+ continue
522
+ try:
523
+ letters.append(index_to_col(int(value) + 1))
524
+ except (TypeError, ValueError):
525
+ continue
526
+ return letters
527
+
528
+
529
+ def profile_table(args) -> tuple[dict[str, Any], list[str]]:
530
+ warnings = []
531
+ if args.header_scan_rows < 1:
532
+ raise ValueError("--header-scan-rows must be at least 1")
533
+ csv_data = envelope_data(
534
+ run_sheets(
535
+ "+csv-get",
536
+ url=args.url,
537
+ spreadsheet_token=args.spreadsheet_token,
538
+ sheet_id=args.sheet_id,
539
+ sheet_name=args.sheet_name,
540
+ flags={
541
+ "range": args.range,
542
+ "max_chars": args.max_chars,
543
+ "skip_hidden": True if args.skip_hidden else None,
544
+ },
545
+ timeout=args.timeout,
546
+ )
547
+ )
548
+ source_range = str(csv_data.get("actual_range") or args.range)
549
+ if csv_data.get("has_more"):
550
+ raise LarkCliError(
551
+ f"+csv-get truncated the requested range at {source_range}; narrow --range before profiling"
552
+ )
553
+ grid = parse_annotated_csv(
554
+ csv_data.get("annotated_csv", ""),
555
+ csv_data.get("col_indices"),
556
+ csv_data.get("row_indices"),
557
+ source_range,
558
+ )
559
+ if grid.row_numbers_inferred:
560
+ warnings.append("CSV row numbers were inferred from the requested range")
561
+ hidden_rows: list[int] = []
562
+ hidden_columns: list[str] = []
563
+ all_hidden_columns: list[str] | None = None
564
+ # Fetched in BOTH modes. Under --skip-hidden the hidden rows/columns are
565
+ # absent from the grid, so hidden_rows/hidden_columns (which are scoped to
566
+ # what the grid contains) come back empty and no warning fires — but the
567
+ # write hints still need to know where the hidden columns are, or
568
+ # safe_append_col can point at one that holds data. all_hidden_columns is
569
+ # the unscoped list used for exactly that.
570
+ try:
571
+ layout = envelope_data(
572
+ run_sheets(
573
+ "+sheet-info",
574
+ url=args.url,
575
+ spreadsheet_token=args.spreadsheet_token,
576
+ sheet_id=args.sheet_id,
577
+ sheet_name=args.sheet_name,
578
+ flags={"include": "hidden_rows,hidden_cols"},
579
+ timeout=args.timeout,
580
+ )
581
+ )
582
+ hidden_rows, hidden_columns = _hidden_rows_and_columns(grid, layout)
583
+ all_hidden_columns = _all_hidden_columns(layout)
584
+ except LarkCliError as exc:
585
+ warnings.append(f"hidden row/column detection unavailable: {exc}")
586
+ return profile_grid(
587
+ grid,
588
+ source_range,
589
+ skip_hidden=args.skip_hidden,
590
+ hidden_rows=hidden_rows,
591
+ hidden_columns=hidden_columns,
592
+ all_hidden_columns=all_hidden_columns,
593
+ header_scan_rows=args.header_scan_rows,
594
+ ), warnings
595
+
596
+
597
+ def main() -> None:
598
+ parser = argparse.ArgumentParser(description=__doc__)
599
+ add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)
600
+ parser.add_argument("--range", required=True)
601
+ parser.add_argument("--max-chars", type=int, default=25000)
602
+ parser.add_argument("--header-scan-rows", type=int, default=20)
603
+ parser.add_argument("--skip-hidden", action="store_true")
604
+ parser.add_argument("--timeout", type=int, default=60)
605
+ args = parser.parse_args()
606
+ try:
607
+ data, warnings = profile_table(args)
608
+ except (LarkCliError, ValueError, TypeError) as exc:
609
+ emit_error(ACTION, str(exc))
610
+ emit_success(ACTION, data, warnings)
611
+
612
+
613
+ if __name__ == "__main__":
614
+ main()