@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,593 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
+ # SPDX-License-Identifier: MIT
4
+ """Detect occupied subtable regions in a Lark sheet."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import csv
10
+ import io
11
+ import re
12
+ from collections import deque
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from lark_sheet_range import RangeBounds, col_to_index, format_range, index_to_col, parse_range, range_union
17
+ from lark_sheet_read_cli import (
18
+ LarkCliError,
19
+ add_spreadsheet_args,
20
+ emit_error,
21
+ emit_success,
22
+ envelope_data,
23
+ resolve_target_sheets,
24
+ run_sheets,
25
+ sheet_identifier,
26
+ sheet_locator,
27
+ sheet_title,
28
+ )
29
+
30
+ ACTION = "detect_subtables"
31
+ ROW_PREFIX_RE = re.compile(r"^\[row=(\d+)\]\s?(.*)$")
32
+ MAX_EXTERNAL_MERGE_ANCHOR_CHECKS = 10
33
+
34
+
35
+ def _inside_quoted_field(lines: list[str]) -> bool:
36
+ """True when the accumulated record has an unterminated quoted field.
37
+
38
+ RFC 4180 escapes a literal quote by doubling it, so both halves of a `""`
39
+ pair count and parity still tracks whether a field is left open. A record
40
+ that is still open must swallow the next physical line verbatim — even one
41
+ that looks like a `[row=N]` prefix, because inside quotes that text is
42
+ ordinary cell content, not a new record.
43
+ """
44
+ return sum(line.count('"') for line in lines) % 2 == 1
45
+
46
+
47
+ @dataclass
48
+ class CsvGrid:
49
+ row_numbers: list[int]
50
+ col_letters: list[str]
51
+ values: list[list[str]]
52
+ row_numbers_inferred: bool = False
53
+
54
+
55
+ @dataclass
56
+ class Component:
57
+ bounds: RangeBounds
58
+ occupied_count: int
59
+
60
+
61
+ def parse_annotated_csv(
62
+ text: str,
63
+ col_indices: list[str] | None = None,
64
+ row_indices: list[int] | None = None,
65
+ source_range: str | None = None,
66
+ ) -> CsvGrid:
67
+ row_numbers: list[int] = []
68
+ values: list[list[str]] = []
69
+ max_cols = 0
70
+ lines = (text or "").splitlines()
71
+ row_numbers_inferred = False
72
+ has_authoritative_rows = isinstance(row_indices, list) and len(row_indices) > 0
73
+
74
+ first_meaningful = next((line for line in lines if line.strip()), "")
75
+ if ROW_PREFIX_RE.match(first_meaningful):
76
+ records = []
77
+ current_lines: list[str] | None = None
78
+ current_row_number: int | None = None
79
+ for line in lines:
80
+ match = ROW_PREFIX_RE.match(line)
81
+ if match and current_lines is not None and _inside_quoted_field(current_lines):
82
+ # Prefix-looking text inside an open quoted field is content.
83
+ current_lines.append(line)
84
+ continue
85
+ if match:
86
+ if current_lines is not None and current_row_number is not None:
87
+ records.append("\n".join(current_lines))
88
+ row_numbers.append(current_row_number)
89
+ current_row_number = int(match.group(1))
90
+ current_lines = [match.group(2)]
91
+ elif current_lines is not None:
92
+ current_lines.append(line)
93
+ if current_lines is not None and current_row_number is not None:
94
+ records.append("\n".join(current_lines))
95
+ row_numbers.append(current_row_number)
96
+
97
+ # Cross-check against the row numbers the server itself reported.
98
+ # _inside_quoted_field decides whether a "[row=N]" line starts a new
99
+ # record or is content inside an open quoted field; when the payload's
100
+ # quoting is malformed (a lone unescaped quote in a cell), that call
101
+ # goes the wrong way and every following line is swallowed into the
102
+ # previous cell — the rows simply vanish, and everything downstream
103
+ # (data_range, last data row, column profiles) is quietly computed from
104
+ # a short grid. row_indices is authoritative and already in hand, so
105
+ # refuse rather than profile a grid that does not match it.
106
+ if has_authoritative_rows and row_numbers != [int(r) for r in row_indices]:
107
+ raise ValueError(
108
+ "annotated_csv did not parse into the rows the server reported "
109
+ f"(parsed {len(row_numbers)} rows {row_numbers[:5]}…, expected "
110
+ f"{len(row_indices)} rows {list(row_indices)[:5]}…) — most likely "
111
+ "an unbalanced quote in a cell. Re-read a narrower --range, or use "
112
+ "+cells-get for this region instead of the CSV path."
113
+ )
114
+
115
+ for record in records:
116
+ parsed = next(csv.reader([record]))
117
+ values.append(parsed)
118
+ max_cols = max(max_cols, len(parsed))
119
+ else:
120
+ reader = csv.reader(io.StringIO(text or ""))
121
+ fallback_start = 1
122
+ if source_range:
123
+ fallback_start = parse_range(
124
+ source_range,
125
+ max_row=1_048_576,
126
+ max_col=18_278,
127
+ ).start_row
128
+ for offset, row in enumerate(reader):
129
+ row_num = None
130
+ if has_authoritative_rows and offset < len(row_indices):
131
+ try:
132
+ row_num = int(row_indices[offset])
133
+ except (TypeError, ValueError):
134
+ # Non-numeric row index: leave row_num as None so the
135
+ # inferred fallback numbering below takes over.
136
+ pass
137
+ if row_num is None:
138
+ row_numbers_inferred = True
139
+ row_numbers.append(row_num if row_num is not None else fallback_start + offset)
140
+ values.append(row)
141
+ max_cols = max(max_cols, len(row))
142
+
143
+ if col_indices:
144
+ col_letters = [str(col) for col in col_indices[:max_cols]]
145
+ while len(col_letters) < max_cols:
146
+ next_col = col_to_index(col_letters[-1]) + 1 if col_letters else len(col_letters) + 1
147
+ col_letters.append(index_to_col(next_col))
148
+ else:
149
+ col_letters = [index_to_col(i) for i in range(1, max_cols + 1)]
150
+
151
+ for row in values:
152
+ row.extend([""] * (len(col_letters) - len(row)))
153
+ inferred = bool(values) and not ROW_PREFIX_RE.match(first_meaningful) and (
154
+ row_numbers_inferred or not has_authoritative_rows
155
+ )
156
+ return CsvGrid(
157
+ row_numbers=row_numbers,
158
+ col_letters=col_letters,
159
+ values=values,
160
+ row_numbers_inferred=inferred,
161
+ )
162
+
163
+
164
+ def _merged_ranges(layout: dict[str, Any]) -> list[str]:
165
+ merges = layout.get("merged_cells") or layout.get("merges") or []
166
+ result = []
167
+ for item in merges:
168
+ if isinstance(item, str):
169
+ result.append(item)
170
+ elif isinstance(item, dict):
171
+ value = item.get("range") or item.get("a1_range") or item.get("range_ref")
172
+ if isinstance(value, str):
173
+ result.append(value)
174
+ return result
175
+
176
+
177
+ def _scan_bounds(grid: CsvGrid) -> RangeBounds | None:
178
+ col_numbers = [col_to_index(col) for col in grid.col_letters]
179
+ if grid.row_numbers and grid.col_letters:
180
+ return RangeBounds(
181
+ min(grid.row_numbers),
182
+ min(col_numbers),
183
+ max(grid.row_numbers),
184
+ max(col_numbers),
185
+ )
186
+ return None
187
+
188
+
189
+ def _external_merge_anchors(grid: CsvGrid, layout: dict[str, Any]) -> dict[str, str]:
190
+ scan_bounds = _scan_bounds(grid)
191
+ if scan_bounds is None:
192
+ return {}
193
+ result = {}
194
+ for merge_ref in _merged_ranges(layout):
195
+ try:
196
+ bounds = parse_range(merge_ref)
197
+ except ValueError:
198
+ continue
199
+ intersects = not (
200
+ bounds.end_row < scan_bounds.start_row
201
+ or bounds.start_row > scan_bounds.end_row
202
+ or bounds.end_col < scan_bounds.start_col
203
+ or bounds.start_col > scan_bounds.end_col
204
+ )
205
+ anchor_in_scan = (
206
+ scan_bounds.start_row <= bounds.start_row <= scan_bounds.end_row
207
+ and scan_bounds.start_col <= bounds.start_col <= scan_bounds.end_col
208
+ )
209
+ if intersects and not anchor_in_scan:
210
+ result[merge_ref] = format_range(
211
+ bounds.start_row, bounds.start_col, bounds.start_row, bounds.start_col
212
+ )
213
+ return result
214
+
215
+
216
+ def _has_value(grid: CsvGrid) -> bool:
217
+ return any(value.strip() for row in grid.values for value in row)
218
+
219
+
220
+ def build_occupancy(
221
+ grid: CsvGrid,
222
+ layout: dict[str, Any],
223
+ *,
224
+ confirmed_external_merges: set[str] | None = None,
225
+ ) -> set[tuple[int, int]]:
226
+ col_numbers = [col_to_index(col) for col in grid.col_letters]
227
+ occupied: set[tuple[int, int]] = set()
228
+ for row_idx, row_num in enumerate(grid.row_numbers):
229
+ for col_idx, value in enumerate(grid.values[row_idx]):
230
+ if value.strip():
231
+ occupied.add((row_num, col_numbers[col_idx]))
232
+
233
+ scan_bounds = _scan_bounds(grid)
234
+
235
+ for merge_ref in _merged_ranges(layout):
236
+ try:
237
+ bounds = parse_range(merge_ref)
238
+ except ValueError:
239
+ continue
240
+ if scan_bounds is None:
241
+ continue
242
+ anchor_in_scan = (
243
+ scan_bounds.start_row <= bounds.start_row <= scan_bounds.end_row
244
+ and scan_bounds.start_col <= bounds.start_col <= scan_bounds.end_col
245
+ )
246
+ if anchor_in_scan and (bounds.start_row, bounds.start_col) not in occupied:
247
+ continue
248
+ if not anchor_in_scan and merge_ref not in (confirmed_external_merges or set()):
249
+ continue
250
+ sr = max(bounds.start_row, scan_bounds.start_row)
251
+ er = min(bounds.end_row, scan_bounds.end_row)
252
+ sc = max(bounds.start_col, scan_bounds.start_col)
253
+ ec = min(bounds.end_col, scan_bounds.end_col)
254
+ if sr > er or sc > ec:
255
+ continue
256
+ for row in range(sr, er + 1):
257
+ for col in range(sc, ec + 1):
258
+ occupied.add((row, col))
259
+ return occupied
260
+
261
+
262
+ def _raw_components(
263
+ occupied: set[tuple[int, int]],
264
+ *,
265
+ adjacent_rows: dict[int, set[int]] | None = None,
266
+ adjacent_cols: dict[int, set[int]] | None = None,
267
+ ) -> list[Component]:
268
+ remaining = set(occupied)
269
+ components = []
270
+ while remaining:
271
+ start = remaining.pop()
272
+ queue = deque([start])
273
+ cells = [start]
274
+ while queue:
275
+ row, col = queue.popleft()
276
+ vertical_rows = adjacent_rows.get(row, set()) if adjacent_rows else {row - 1, row + 1}
277
+ neighbors = [(neighbor_row, col) for neighbor_row in vertical_rows]
278
+ # Columns get the same treatment as rows: with --skip-hidden the
279
+ # returned columns can be non-consecutive (A, C when B is hidden),
280
+ # so col±1 would split visually adjacent data into two components.
281
+ horizontal_cols = adjacent_cols.get(col, set()) if adjacent_cols else {col - 1, col + 1}
282
+ neighbors.extend((row, neighbor_col) for neighbor_col in horizontal_cols)
283
+ for neighbor in neighbors:
284
+ if neighbor in remaining:
285
+ remaining.remove(neighbor)
286
+ queue.append(neighbor)
287
+ cells.append(neighbor)
288
+ rows = [cell[0] for cell in cells]
289
+ cols = [cell[1] for cell in cells]
290
+ components.append(
291
+ Component(
292
+ RangeBounds(min(rows), min(cols), max(rows), max(cols)),
293
+ len(cells),
294
+ )
295
+ )
296
+ return components
297
+
298
+
299
+ def _box_gap_mergeable(a: RangeBounds, b: RangeBounds, gap_rows: int, gap_cols: int) -> bool:
300
+ cols_overlap = not (a.end_col < b.start_col or b.end_col < a.start_col)
301
+ rows_overlap = not (a.end_row < b.start_row or b.end_row < a.start_row)
302
+ vertical_gap = max(b.start_row - a.end_row - 1, a.start_row - b.end_row - 1, 0)
303
+ horizontal_gap = max(b.start_col - a.end_col - 1, a.start_col - b.end_col - 1, 0)
304
+ return (cols_overlap and vertical_gap <= gap_rows) or (
305
+ rows_overlap and horizontal_gap <= gap_cols
306
+ )
307
+
308
+
309
+ def merge_components(
310
+ components: list[Component], *, gap_rows: int = 1, gap_cols: int = 0
311
+ ) -> list[Component]:
312
+ merged = components[:]
313
+ changed = True
314
+ while changed:
315
+ changed = False
316
+ next_components: list[Component] = []
317
+ used = [False] * len(merged)
318
+ for i, comp in enumerate(merged):
319
+ if used[i]:
320
+ continue
321
+ current = Component(comp.bounds, comp.occupied_count)
322
+ used[i] = True
323
+ for j in range(i + 1, len(merged)):
324
+ if used[j]:
325
+ continue
326
+ other = merged[j]
327
+ if _box_gap_mergeable(current.bounds, other.bounds, gap_rows, gap_cols):
328
+ current = Component(
329
+ range_union(current.bounds, other.bounds),
330
+ current.occupied_count + other.occupied_count,
331
+ )
332
+ used[j] = True
333
+ changed = True
334
+ next_components.append(current)
335
+ merged = next_components
336
+ return merged
337
+
338
+
339
+ def _row_values(grid: CsvGrid, bounds: RangeBounds, row_num: int) -> list[str]:
340
+ if row_num not in grid.row_numbers:
341
+ return []
342
+ row = grid.values[grid.row_numbers.index(row_num)]
343
+ values = []
344
+ for col_idx, col_letter in enumerate(grid.col_letters):
345
+ col_num = col_to_index(col_letter)
346
+ if bounds.start_col <= col_num <= bounds.end_col:
347
+ values.append(row[col_idx] if col_idx < len(row) else "")
348
+ return values
349
+
350
+
351
+ def header_candidates(grid: CsvGrid, bounds: RangeBounds) -> list[int]:
352
+ candidates = []
353
+ for row in range(bounds.start_row, min(bounds.end_row, bounds.start_row + 4) + 1):
354
+ values = _row_values(grid, bounds, row)
355
+ non_empty = [value for value in values if value.strip()]
356
+ if len(non_empty) >= max(1, min(2, bounds.col_count)):
357
+ candidates.append(row)
358
+ return candidates
359
+
360
+
361
+ def kind_guess(bounds: RangeBounds, density: float) -> str:
362
+ if bounds.row_count >= 3 and bounds.col_count >= 2 and density >= 0.25:
363
+ return "data_table"
364
+ if bounds.row_count <= 2 or bounds.col_count <= 1:
365
+ return "note_or_label"
366
+ if density < 0.25:
367
+ return "sparse_block"
368
+ return "summary_block"
369
+
370
+
371
+ def summarize_components(grid: CsvGrid, components: list[Component], min_cells: int) -> list[dict[str, Any]]:
372
+ result = []
373
+ for idx, comp in enumerate(
374
+ sorted(components, key=lambda item: (item.bounds.start_row, item.bounds.start_col)),
375
+ start=1,
376
+ ):
377
+ if comp.occupied_count < min_cells:
378
+ continue
379
+ area = comp.bounds.row_count * comp.bounds.col_count
380
+ density = comp.occupied_count / area if area else 0
381
+ samples = []
382
+ for row in range(comp.bounds.start_row, min(comp.bounds.end_row, comp.bounds.start_row + 2) + 1):
383
+ samples.append(_row_values(grid, comp.bounds, row))
384
+ result.append(
385
+ {
386
+ "id": f"T{idx}",
387
+ "range": format_range(
388
+ comp.bounds.start_row,
389
+ comp.bounds.start_col,
390
+ comp.bounds.end_row,
391
+ comp.bounds.end_col,
392
+ ),
393
+ "rows": comp.bounds.row_count,
394
+ "cols": comp.bounds.col_count,
395
+ "occupied_cells": comp.occupied_count,
396
+ "density": round(density, 4),
397
+ "header_candidates": header_candidates(grid, comp.bounds),
398
+ "kind_guess": kind_guess(comp.bounds, density),
399
+ "sample": samples,
400
+ }
401
+ )
402
+ return result
403
+
404
+
405
+ def detect_subtables(args) -> tuple[dict[str, Any], list[str]]:
406
+ warnings: list[str] = []
407
+ workbook = envelope_data(
408
+ run_sheets(
409
+ "+workbook-info",
410
+ url=args.url,
411
+ spreadsheet_token=args.spreadsheet_token,
412
+ timeout=args.timeout,
413
+ )
414
+ )
415
+ sheet = resolve_target_sheets(
416
+ workbook,
417
+ sheet_id=args.sheet_id,
418
+ sheet_name=args.sheet_name,
419
+ require_one=True,
420
+ )[0]
421
+ sid = sheet_identifier(sheet)
422
+ title = sheet_title(sheet)
423
+ locator = sheet_locator(sheet)
424
+
425
+ col_count = min(int(sheet.get("column_count") or args.max_scan_cols), args.max_scan_cols)
426
+ row_count = min(int(sheet.get("row_count") or args.max_scan_rows), args.max_scan_rows)
427
+ scan_range = args.range or f"A1:{index_to_col(max(1, col_count))}{max(1, row_count)}"
428
+ if not args.range:
429
+ if int(sheet.get("column_count") or 0) > args.max_scan_cols:
430
+ warnings.append(f"scan clipped to first {args.max_scan_cols} columns")
431
+ if int(sheet.get("row_count") or 0) > args.max_scan_rows:
432
+ warnings.append(f"scan clipped to first {args.max_scan_rows} rows")
433
+
434
+ layout = envelope_data(
435
+ run_sheets(
436
+ "+sheet-info",
437
+ url=args.url,
438
+ spreadsheet_token=args.spreadsheet_token,
439
+ **locator,
440
+ flags={"include": "merges,hidden_rows,hidden_cols"},
441
+ timeout=args.timeout,
442
+ )
443
+ )
444
+ csv_data = envelope_data(
445
+ run_sheets(
446
+ "+csv-get",
447
+ url=args.url,
448
+ spreadsheet_token=args.spreadsheet_token,
449
+ **locator,
450
+ flags={
451
+ "range": scan_range,
452
+ "max_chars": args.max_chars,
453
+ "skip_hidden": True if args.skip_hidden else None,
454
+ },
455
+ timeout=args.timeout,
456
+ )
457
+ )
458
+ actual_range = str(csv_data.get("actual_range") or scan_range)
459
+ if csv_data.get("has_more"):
460
+ raise LarkCliError(
461
+ f"+csv-get truncated the scan range at {actual_range}; narrow --range before detecting subtables"
462
+ )
463
+
464
+ grid = parse_annotated_csv(
465
+ csv_data.get("annotated_csv", ""),
466
+ csv_data.get("col_indices"),
467
+ csv_data.get("row_indices"),
468
+ actual_range,
469
+ )
470
+ if grid.row_numbers_inferred:
471
+ warnings.append("CSV row numbers were inferred from the requested range")
472
+ hidden_rows_raw = layout.get("hidden_rows") or []
473
+ hidden_row_indexes = {
474
+ int(value) + 1
475
+ for value in hidden_rows_raw
476
+ if isinstance(value, (int, str)) and str(value).isdigit()
477
+ }
478
+ hidden_columns_raw = layout.get("hidden_cols") or layout.get("hidden_columns") or []
479
+ hidden_col_letters = set()
480
+ for value in hidden_columns_raw if isinstance(hidden_columns_raw, list) else []:
481
+ if isinstance(value, str) and value.isalpha():
482
+ hidden_col_letters.add(value.upper())
483
+ elif isinstance(value, (int, str)) and str(value).isdigit():
484
+ hidden_col_letters.add(index_to_col(int(value) + 1))
485
+ hidden_rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)
486
+ hidden_columns = [col for col in grid.col_letters if col.upper() in hidden_col_letters]
487
+ if hidden_rows or hidden_columns:
488
+ warnings.append("scan includes hidden rows or columns; pass --skip-hidden to exclude them")
489
+ confirmed_external_merges = set()
490
+ external_merge_anchors = list(_external_merge_anchors(grid, layout).items())
491
+ if len(external_merge_anchors) > MAX_EXTERNAL_MERGE_ANCHOR_CHECKS:
492
+ warnings.append(
493
+ f"skipped confirmation for {len(external_merge_anchors) - MAX_EXTERNAL_MERGE_ANCHOR_CHECKS} "
494
+ f"external merge anchors (limit: {MAX_EXTERNAL_MERGE_ANCHOR_CHECKS})"
495
+ )
496
+ for merge_ref, anchor in external_merge_anchors[:MAX_EXTERNAL_MERGE_ANCHOR_CHECKS]:
497
+ try:
498
+ anchor_data = envelope_data(
499
+ run_sheets(
500
+ "+csv-get",
501
+ url=args.url,
502
+ spreadsheet_token=args.spreadsheet_token,
503
+ **locator,
504
+ flags={
505
+ "range": anchor,
506
+ "max_chars": 1024,
507
+ "skip_hidden": True if args.skip_hidden else None,
508
+ },
509
+ timeout=args.timeout,
510
+ )
511
+ )
512
+ anchor_grid = parse_annotated_csv(
513
+ anchor_data.get("annotated_csv", ""),
514
+ anchor_data.get("col_indices"),
515
+ anchor_data.get("row_indices"),
516
+ anchor,
517
+ )
518
+ if _has_value(anchor_grid):
519
+ confirmed_external_merges.add(merge_ref)
520
+ except (LarkCliError, ValueError) as exc:
521
+ warnings.append(f"could not confirm merge anchor {anchor}: {exc}")
522
+ occupied = build_occupancy(
523
+ grid,
524
+ layout,
525
+ confirmed_external_merges=confirmed_external_merges,
526
+ )
527
+ adjacent_rows = None
528
+ adjacent_cols = None
529
+ if args.skip_hidden:
530
+ adjacent_rows = {}
531
+ for previous, current in zip(grid.row_numbers, grid.row_numbers[1:]):
532
+ adjacent_rows.setdefault(previous, set()).add(current)
533
+ adjacent_rows.setdefault(current, set()).add(previous)
534
+ col_numbers = [col_to_index(col) for col in grid.col_letters]
535
+ adjacent_cols = {}
536
+ for previous, current in zip(col_numbers, col_numbers[1:]):
537
+ adjacent_cols.setdefault(previous, set()).add(current)
538
+ adjacent_cols.setdefault(current, set()).add(previous)
539
+ raw_components = _raw_components(
540
+ occupied,
541
+ adjacent_rows=adjacent_rows,
542
+ adjacent_cols=adjacent_cols,
543
+ )
544
+ if len(raw_components) > args.max_merge_components:
545
+ warnings.append(
546
+ f"skipped gap-based component merging for {len(raw_components)} components "
547
+ f"(limit: {args.max_merge_components})"
548
+ )
549
+ components = raw_components
550
+ else:
551
+ components = merge_components(
552
+ raw_components,
553
+ gap_rows=args.gap_rows,
554
+ gap_cols=args.gap_cols,
555
+ )
556
+ subtables = summarize_components(grid, components, args.min_cells)
557
+ return {
558
+ "sheet_id": sid,
559
+ "sheet": title,
560
+ "scan_range": scan_range,
561
+ "actual_range": actual_range,
562
+ "visibility": {
563
+ "skip_hidden": args.skip_hidden,
564
+ "hidden_rows_in_range": hidden_rows,
565
+ "hidden_columns_in_range": hidden_columns,
566
+ },
567
+ "subtables": subtables,
568
+ }, warnings
569
+
570
+
571
+ def main() -> None:
572
+ parser = argparse.ArgumentParser(description=__doc__)
573
+ add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)
574
+ parser.add_argument("--range")
575
+ parser.add_argument("--max-scan-rows", type=int, default=5000)
576
+ parser.add_argument("--max-scan-cols", type=int, default=200)
577
+ parser.add_argument("--gap-rows", type=int, default=1)
578
+ parser.add_argument("--gap-cols", type=int, default=0)
579
+ parser.add_argument("--min-cells", type=int, default=2)
580
+ parser.add_argument("--max-merge-components", type=int, default=2000)
581
+ parser.add_argument("--max-chars", type=int, default=25000)
582
+ parser.add_argument("--skip-hidden", action="store_true")
583
+ parser.add_argument("--timeout", type=int, default=60)
584
+ args = parser.parse_args()
585
+ try:
586
+ data, warnings = detect_subtables(args)
587
+ except (LarkCliError, ValueError, TypeError) as exc:
588
+ emit_error(ACTION, str(exc))
589
+ emit_success(ACTION, data, warnings)
590
+
591
+
592
+ if __name__ == "__main__":
593
+ main()