@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.
- package/package.json +2 -2
- package/skills/lark-apps/SKILL.md +39 -6
- package/skills/lark-apps/references/lark-apps-cloud-dev.md +5 -4
- package/skills/lark-apps/references/lark-apps-create.md +6 -3
- package/skills/lark-apps/references/lark-apps-get.md +1 -1
- package/skills/lark-apps/references/lark-apps-list.md +1 -1
- package/skills/lark-apps/references/lark-apps-local-dev.md +27 -1
- package/skills/lark-apps/references/lark-apps-release-create.md +1 -1
- package/skills/lark-base/SKILL.md +14 -6
- package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +17 -1
- package/skills/lark-base/references/lark-base-dashboard.md +17 -4
- package/skills/lark-base/references/lark-base-data-query-guide.md +8 -0
- package/skills/lark-base/references/lark-base-field-create.md +19 -8
- package/skills/lark-base/references/lark-base-field-json.md +5 -2
- package/skills/lark-calendar/SKILL.md +1 -1
- package/skills/lark-doc/SKILL.md +26 -61
- package/skills/lark-doc/references/genres/business-analysis.md +30 -0
- package/skills/lark-doc/references/genres/data-report.md +32 -0
- package/skills/lark-doc/references/genres/email.md +38 -0
- package/skills/lark-doc/references/genres/execution-plan.md +27 -0
- package/skills/lark-doc/references/genres/formal-doc.md +37 -0
- package/skills/lark-doc/references/genres/meeting-minutes.md +24 -0
- package/skills/lark-doc/references/genres/memo-brief.md +25 -0
- package/skills/lark-doc/references/genres/official-redhead.md +73 -0
- package/skills/lark-doc/references/genres/prd.md +26 -0
- package/skills/lark-doc/references/genres/proposal.md +24 -0
- package/skills/lark-doc/references/genres/research-report.md +32 -0
- package/skills/lark-doc/references/genres/retrospective.md +25 -0
- package/skills/lark-doc/references/genres/route-consumer.md +37 -0
- package/skills/lark-doc/references/genres/route-creative.md +36 -0
- package/skills/lark-doc/references/genres/route-knowledge.md +39 -0
- package/skills/lark-doc/references/genres/route-marketing.md +40 -0
- package/skills/lark-doc/references/genres/route-media.md +36 -0
- package/skills/lark-doc/references/genres/route-opinion.md +38 -0
- package/skills/lark-doc/references/genres/route-personal-brand.md +36 -0
- package/skills/lark-doc/references/genres/route-platform.md +9 -0
- package/skills/lark-doc/references/genres/route-report.md +10 -0
- package/skills/lark-doc/references/genres/route-workplace.md +17 -0
- package/skills/lark-doc/references/genres/sop-tutorial.md +41 -0
- package/skills/lark-doc/references/genres/technical-doc.md +39 -0
- package/skills/lark-doc/references/genres/wechat.md +39 -0
- package/skills/lark-doc/references/genres/weekly-report.md +24 -0
- package/skills/lark-doc/references/genres/white-paper.md +32 -0
- package/skills/lark-doc/references/genres/xiaohongshu.md +38 -0
- package/skills/lark-doc/references/lark-doc-create-workflow.md +121 -0
- package/skills/lark-doc/references/lark-doc-create.md +22 -48
- package/skills/lark-doc/references/lark-doc-fetch.md +75 -92
- package/skills/lark-doc/references/lark-doc-history.md +16 -15
- package/skills/lark-doc/references/lark-doc-md.md +5 -1
- package/skills/lark-doc/references/lark-doc-media-download.md +2 -1
- package/skills/lark-doc/references/lark-doc-script.md +76 -0
- package/skills/lark-doc/references/lark-doc-update.md +70 -222
- package/skills/lark-doc/references/lark-doc-whiteboard.md +5 -9
- package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +17 -12
- package/skills/lark-doc/references/lark-doc-xml.md +38 -167
- package/skills/lark-drive/SKILL.md +7 -5
- package/skills/lark-drive/references/lark-drive-apply-permission.md +1 -1
- package/skills/lark-drive/references/lark-drive-copy.md +87 -0
- package/skills/lark-drive/references/lark-drive-download.md +2 -1
- package/skills/lark-drive/references/lark-drive-export.md +3 -0
- package/skills/lark-drive/references/lark-drive-task-result.md +3 -0
- package/skills/lark-drive/references/lark-drive-update-title.md +78 -0
- package/skills/lark-event/SKILL.md +7 -4
- package/skills/lark-event/references/lark-event-vc.md +8 -2
- package/skills/lark-im/SKILL.md +8 -8
- package/skills/lark-im/references/lark-im-chat-list.md +9 -2
- package/skills/lark-im/references/lark-im-chat-members-list.md +7 -4
- package/skills/lark-im/references/lark-im-chat-messages-list.md +10 -3
- package/skills/lark-im/references/lark-im-chat-search.md +9 -2
- package/skills/lark-im/references/lark-im-feed-group-list-item.md +2 -2
- package/skills/lark-im/references/lark-im-feed-group-list.md +2 -2
- package/skills/lark-im/references/lark-im-feed-shortcut-list.md +1 -1
- package/skills/lark-im/references/lark-im-flag-list.md +2 -2
- package/skills/lark-im/references/lark-im-message-enrichment.md +1 -1
- package/skills/lark-im/references/lark-im-messages-resources-download.md +19 -25
- package/skills/lark-im/references/lark-im-messages-search.md +4 -5
- package/skills/lark-im/references/lark-im-threads-messages-list.md +8 -4
- package/skills/lark-mail/references/lark-mail-triage.md +19 -4
- package/skills/lark-minutes/SKILL.md +1 -1
- package/skills/lark-minutes/references/lark-minutes-search.md +6 -7
- package/skills/lark-shared/SKILL.md +3 -3
- package/skills/lark-sheets/SKILL.md +83 -82
- package/skills/lark-sheets/references/lark-sheets-batch-update.md +13 -58
- package/skills/lark-sheets/references/lark-sheets-chart.md +2 -1
- package/skills/lark-sheets/references/lark-sheets-conditional-format.md +1 -1
- package/skills/lark-sheets/references/lark-sheets-range-operations.md +5 -5
- package/skills/lark-sheets/references/lark-sheets-read-data.md +80 -6
- package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +21 -10
- package/skills/lark-sheets/references/lark-sheets-styles-put.md +93 -0
- package/skills/lark-sheets/references/lark-sheets-visual-standards.md +2 -2
- package/skills/lark-sheets/references/lark-sheets-workbook.md +4 -3
- package/skills/lark-sheets/references/lark-sheets-write-cells.md +40 -12
- package/skills/lark-sheets/scripts/lark_detect_subtables.py +593 -0
- package/skills/lark-sheets/scripts/lark_inspect_workbook.py +188 -0
- package/skills/lark-sheets/scripts/lark_profile_table.py +614 -0
- package/skills/lark-sheets/scripts/lark_sheet_range.py +176 -0
- package/skills/lark-sheets/scripts/lark_sheet_read_cli.py +184 -0
- package/skills/lark-sheets/scripts/sheets_df.py +21 -3
- package/skills/lark-slides/SKILL.md +27 -44
- package/skills/lark-slides/references/lark-slides-add-slide.md +92 -0
- package/skills/lark-slides/references/lark-slides-create.md +77 -65
- package/skills/lark-slides/references/lark-slides-delete-slide.md +65 -0
- package/skills/lark-slides/references/lark-slides-edit-workflows.md +6 -7
- package/skills/lark-slides/references/lark-slides-media-upload.md +3 -25
- package/skills/lark-slides/references/lark-slides-replace-slide.md +22 -1
- package/skills/lark-slides/references/lark-slides-screenshot.md +31 -13
- package/skills/lark-slides/references/lark-slides-update-slide.md +146 -0
- package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +31 -8
- package/skills/lark-slides/references/slides_chart_demo.xml +1 -2
- package/skills/lark-slides/references/slides_xml_schema_definition.xml +48 -4
- package/skills/lark-slides/references/troubleshooting.md +7 -8
- package/skills/lark-slides/references/validation-checklist.md +4 -4
- package/skills/lark-slides/references/xml-schema-quick-ref.md +23 -11
- package/skills/lark-slides/scripts/sxsd_validator.py +154 -10
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +360 -76
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +1138 -214
- package/skills/lark-whiteboard/SKILL.md +15 -8
- package/skills/lark-whiteboard/references/lark-whiteboard-export.md +4 -3
- package/skills/lark-whiteboard/references/lark-whiteboard-update.md +4 -4
- package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +19 -17
- package/skills/lark-whiteboard/routes/dsl.md +8 -2
- package/skills/lark-whiteboard/routes/mermaid.md +1 -1
- package/skills/lark-whiteboard/routes/svg-edit.md +5 -2
- package/skills/lark-whiteboard/routes/svg.md +3 -1
- package/skills/lark-whiteboard/scenes/mention.md +71 -0
- package/skills/lark-wiki/SKILL.md +5 -3
- package/skills/lark-wiki/references/lark-wiki-delete-space.md +6 -3
- package/skills/lark-doc/references/lark-doc-word-stat.md +0 -93
- package/skills/lark-doc/references/style/lark-doc-create-workflow.md +0 -47
- package/skills/lark-doc/references/style/lark-doc-style.md +0 -68
- package/skills/lark-doc/references/style/lark-doc-update-workflow.md +0 -48
- package/skills/lark-doc/scripts/doc_word_stat.py +0 -1243
- package/skills/lark-slides/references/lark-slides-replace-pages.md +0 -95
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +0 -219
- 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()
|