Skip to content

Commit aed2757

Browse files
authored
Merge pull request #48 from ncdcdev/feat/include-frozen-rows
feat: add include_frozen_rows parameter for automatic header inclusion
2 parents 7780184 + 4081e0d commit aed2757

6 files changed

Lines changed: 470 additions & 31 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ Two authentication methods are supported:
3535
- Read or search Excel files in SharePoint
3636
- Search mode: find cells containing specific text with `query` parameter
3737
- Read mode: get data from specific sheets/ranges with `sheet` and `cell_range` parameters
38+
- **Automatic header inclusion**: when `cell_range` is specified, frozen rows (headers) are automatically included by default
39+
- Set `include_frozen_rows=False` to get only the specified range
3840
- Response includes cell data in `rows` (value and coordinate) and structural information when available
39-
- Structural info: sheet name, dimensions, freeze_panes (when present), merged_ranges (when merged cells exist)
41+
- Structural info: sheet name, dimensions, frozen_rows, frozen_cols, freeze_panes (when present), merged_ranges (when merged cells exist)
4042
- No Excel Services dependency - uses direct file download + openpyxl parsing
4143

4244
### OneDrive Support

README_ja.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ stdioとHTTPの両方のトランスポートに対応しています。
3535
- SharePoint上のExcelファイルの読み取りと検索
3636
- 検索モード: `query`パラメータで特定テキストを含むセルを検索
3737
- 読み取りモード: `sheet``cell_range`パラメータで特定シート/範囲を取得
38+
- **ヘッダー自動追加**: `cell_range`指定時、デフォルトで固定行(ヘッダー)を自動的に含める
39+
- `include_frozen_rows=False`を指定すると、指定範囲のみを取得
3840
- レスポンスには`rows`内のセルデータ(値と座標)と構造情報(利用可能な場合)を含む
39-
- 構造情報: シート名、dimensions、freeze_panes(存在する場合)、merged_ranges(結合セルが存在する場合)
41+
- 構造情報: シート名、dimensions、frozen_rows、frozen_cols、freeze_panes(存在する場合)、merged_ranges(結合セルが存在する場合)
4042
- Excel Services不要 - 直接ファイルダウンロード+openpyxl解析方式
4143

4244
### OneDrive対応

src/server.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ def sharepoint_excel(
453453
query: str | None = None,
454454
sheet: str | None = None,
455455
cell_range: str | None = None,
456+
include_frozen_rows: bool = True,
456457
ctx: Context | None = None,
457458
) -> str:
458459
"""
@@ -467,6 +468,9 @@ def sharepoint_excel(
467468
- 列のみ: "A:D" も可(自動的に行範囲が追加されます)
468469
- ⚠️ 単一列/行の部分範囲は開始側のみ 1/A に自動拡張されます
469470
例: "J50:J100" → "J1:J100"(開始行が 1 行目に拡張されます)
471+
include_frozen_rows: cell_range指定時に固定行を自動追加(デフォルト: True)
472+
True: frozen_rowsで指定された行(通常はヘッダー)を自動的に取得
473+
False: 指定されたcell_rangeのみを取得
470474
ctx: FastMCP context (injected automatically)
471475
472476
Returns:
@@ -493,6 +497,7 @@ def sharepoint_excel(
493497
file_path,
494498
sheet_name=sheet,
495499
cell_range=cell_range,
500+
include_frozen_rows=include_frozen_rows,
496501
)
497502

498503
except Exception as e:
@@ -532,10 +537,15 @@ def register_tools():
532537
"Read or search Excel files in SharePoint. "
533538
"Search mode: use 'query' parameter to find cells containing specific text (returns cell locations). "
534539
"Read mode: use 'sheet' and 'cell_range' parameters to retrieve data from specific sections. "
540+
"When cell_range is specified with include_frozen_rows=True (default), frozen rows are automatically "
541+
"included even if they are outside the specified range. frozen_rows indicates the number of header rows "
542+
"frozen at the top of the sheet (typically column headers). "
535543
"Response includes cell data in 'rows' (value and coordinate) and structural information "
536-
"(sheet name, dimensions, freeze_panes when present, merged_ranges when merged cells exist). "
537-
"Recommended workflow: 1) Search with query to locate relevant content, "
538-
"2) Read specific range based on search results."
544+
"(sheet name, dimensions, frozen_rows, frozen_cols, freeze_panes when present, merged_ranges when merged cells exist). "
545+
"Header detection: Cannot be auto-detected from frozen_rows. "
546+
"ALWAYS read exactly 5 rows for header check: 'A1:Z5' (NOT 'A1:Z50' or more). "
547+
"Prefer 'query' search when possible to locate data first. "
548+
"Workflow: 1) Search OR read 'A1:Z5', 2) Read specific range only."
539549
)
540550
)(sharepoint_excel)
541551
logging.info("Registered tool: sharepoint_excel")

src/sharepoint_excel.py

Lines changed: 158 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def parse_to_json(
106106
file_path: str,
107107
sheet_name: str | None = None,
108108
cell_range: str | None = None,
109+
include_frozen_rows: bool = True,
109110
) -> str:
110111
"""
111112
Excelファイルを解析してJSON形式で返す
@@ -114,11 +115,15 @@ def parse_to_json(
114115
file_path: Excelファイルのパス
115116
sheet_name: 特定シートのみ取得(Noneで全シート)
116117
cell_range: セル範囲指定(例: "A1:D10")
118+
include_frozen_rows: cell_range指定時に固定行(ヘッダー)を自動追加
119+
True(デフォルト): frozen_rowsで指定された行を自動的に取得
120+
False: 指定されたcell_rangeのみを取得
117121
118122
Returns:
119123
JSON文字列
120124
- 各セルのデータ: value(値)、coordinate(座標)
121-
- 構造情報: シート名、dimensions(範囲)
125+
- 構造情報: シート名、dimensions(シート全体のセル範囲、例: "A1:D10")
126+
- 構造情報: frozen_rows(固定行数)、frozen_cols(固定列数)
122127
- 条件付き構造情報: freeze_panes(存在する場合)、merged_ranges(結合セルが存在する場合)
123128
"""
124129
logger.info(
@@ -199,6 +204,7 @@ def parse_to_json(
199204
sheet_data = self._parse_sheet(
200205
sheet,
201206
cell_range,
207+
include_frozen_rows,
202208
)
203209
result["sheets"].append(sheet_data)
204210

@@ -283,17 +289,118 @@ def _scan_sheet(
283289
}
284290
)
285291

292+
def _calculate_header_range(
293+
self, cell_range: str, frozen_rows: int
294+
) -> str | None:
295+
"""
296+
セル範囲に対してfrozen_rowsに基づくヘッダー範囲を計算
297+
298+
Args:
299+
cell_range: セル範囲(例: "A5:D10")
300+
拡張後のeffective_rangeを渡すこと(軸拡張済み)
301+
frozen_rows: 固定行数
302+
303+
Returns:
304+
ヘッダー範囲(例: "A1:D2")またはNone
305+
306+
早期リターン条件:
307+
- frozen_rows=0: ヘッダーなし
308+
- start_row == 1: 既に1行目から開始(ヘッダー全体を含む)
309+
310+
部分的な重なり処理:
311+
- frozen_rows=2, cell_range="A2:B6" の場合
312+
→ 不足分 "A1:B1" を返して、最終的に "A1:B6" になる
313+
"""
314+
# frozen_rowsが0の場合はヘッダーなし
315+
if frozen_rows == 0:
316+
return None
317+
318+
# セル範囲を解析
319+
# "A5:D10" -> start="A5", end="D10"
320+
if ":" in cell_range:
321+
start, end = cell_range.split(":")
322+
else:
323+
# 単一セル(例: "B5")
324+
start = end = cell_range
325+
326+
# 開始セルの座標を解析
327+
start_col_letter, start_row = coordinate_from_string(start)
328+
end_col_letter, _ = coordinate_from_string(end)
329+
330+
# 既に1行目から開始している場合は追加不要(ヘッダー全体を含む)
331+
if start_row == 1:
332+
return None
333+
334+
# 部分的な重なりがある場合は、不足している上部のヘッダー行を追加
335+
if start_row <= frozen_rows:
336+
# 1行目から(start_row-1)行目までを追加
337+
header_range = f"{start_col_letter}1:{end_col_letter}{start_row - 1}"
338+
return header_range
339+
340+
# ヘッダー範囲を計算: {start_col}1:{end_col}{frozen_rows}
341+
header_range = f"{start_col_letter}1:{end_col_letter}{frozen_rows}"
342+
return header_range
343+
344+
def _merge_ranges(self, range1: str, range2: str) -> str:
345+
"""
346+
2つのセル範囲を結合して、最小の包含範囲を返す
347+
348+
Args:
349+
range1: 範囲1(例: "A1:B2")
350+
range2: 範囲2(例: "A4:B6")
351+
352+
Returns:
353+
結合された範囲(例: "A1:B6")
354+
"""
355+
# 範囲1を解析
356+
if ":" in range1:
357+
start1, end1 = range1.split(":")
358+
else:
359+
start1 = end1 = range1
360+
361+
# 範囲2を解析
362+
if ":" in range2:
363+
start2, end2 = range2.split(":")
364+
else:
365+
start2 = end2 = range2
366+
367+
# 座標を取得
368+
col1_start, row1_start = coordinate_from_string(start1)
369+
col1_end, row1_end = coordinate_from_string(end1)
370+
col2_start, row2_start = coordinate_from_string(start2)
371+
col2_end, row2_end = coordinate_from_string(end2)
372+
373+
# 最小/最大の列を決定
374+
col_start_idx = min(
375+
column_index_from_string(col1_start), column_index_from_string(col2_start)
376+
)
377+
col_end_idx = max(
378+
column_index_from_string(col1_end), column_index_from_string(col2_end)
379+
)
380+
381+
# 最小/最大の行を決定
382+
row_start = min(row1_start, row2_start)
383+
row_end = max(row1_end, row2_end)
384+
385+
# 列インデックスを文字に変換
386+
col_start = get_column_letter(col_start_idx)
387+
col_end = get_column_letter(col_end_idx)
388+
389+
return f"{col_start}{row_start}:{col_end}{row_end}"
390+
286391
def _parse_sheet(
287392
self,
288393
sheet,
289394
cell_range: str | None = None,
395+
include_frozen_rows: bool = True,
290396
) -> dict[str, Any]:
291397
"""
292398
シートを解析してdict形式で返す
293399
294400
Args:
295401
sheet: openpyxl Worksheet
296402
cell_range: セル範囲指定(例: "A1:D10")
403+
include_frozen_rows: cell_range指定時に固定行(ヘッダー)を自動追加
297404
298405
Returns:
299406
シートデータのdict
@@ -332,6 +439,7 @@ def _parse_sheet(
332439
# セル範囲の正規化・拡張(cell_rangeがある場合)
333440
# マージセル情報のキャッシュに使用するため、先に計算する
334441
effective_range_for_merge = None
442+
header_range = None # ヘッダー範囲(再利用のため事前に初期化)
335443
if cell_range:
336444
sheet_data["requested_range"] = cell_range
337445
effective_range = self._normalize_column_range(cell_range, sheet)
@@ -355,18 +463,17 @@ def _parse_sheet(
355463
sheet_data["effective_range"] = effective_range
356464
effective_range_for_merge = effective_range
357465

358-
# マージセル情報をキャッシュ(パフォーマンス最適化)
359-
# 計算済みのeffective_range(effective_range_for_merge)を渡してキャッシュを構築し、
360-
# 戻り値としてmerged_ranges(結合セル範囲の一覧)を取得することで重複計算を回避
361-
merged_cell_map, merged_anchor_value_map, merged_ranges = (
362-
self._build_merged_cell_cache(sheet, effective_range_for_merge)
363-
)
364-
365-
# ここは「結合セルがある時だけ」返す
366-
if merged_ranges:
367-
sheet_data["merged_ranges"] = merged_ranges
466+
# ヘッダー自動追加の場合、マージセルキャッシュにもヘッダー範囲を含める
467+
if include_frozen_rows and frozen_rows > 0:
468+
header_range = self._calculate_header_range(effective_range, frozen_rows)
469+
if header_range:
470+
# ヘッダー範囲とデータ範囲を結合した範囲を計算
471+
effective_range_for_merge = self._merge_ranges(
472+
header_range, effective_range
473+
)
368474

369-
# セル範囲のデータサイズ検証とデータ取得
475+
# データサイズ検証(DoS対策)
476+
# マージセルキャッシュ構築前に検証することで、巨大な範囲によるメモリ枯渇を防ぐ
370477
all_rows = []
371478

372479
if cell_range:
@@ -384,18 +491,6 @@ def _parse_sheet(
384491
f"例: cell_range='A1:Z1000'"
385492
)
386493

387-
else:
388-
# 通常のセル範囲取得
389-
range_data = sheet[effective_range]
390-
rows_to_process = self._normalize_range_data(range_data)
391-
all_rows.extend(
392-
self._parse_rows(
393-
rows_to_process,
394-
merged_cell_map,
395-
merged_anchor_value_map,
396-
)
397-
)
398-
399494
elif sheet.dimensions:
400495
# シート全体を取得
401496
# データサイズ検証(DoS対策)
@@ -411,6 +506,45 @@ def _parse_sheet(
411506
f"例: cell_range='A1:Z1000'"
412507
)
413508

509+
# データサイズ検証後にマージセル情報をキャッシュ(パフォーマンス最適化 + DoS対策)
510+
# 計算済みのeffective_range(effective_range_for_merge)を渡してキャッシュを構築し、
511+
# 戻り値としてmerged_ranges(結合セル範囲の一覧)を取得することで重複計算を回避
512+
merged_cell_map, merged_anchor_value_map, merged_ranges = (
513+
self._build_merged_cell_cache(sheet, effective_range_for_merge)
514+
)
515+
516+
# ここは「結合セルがある時だけ」返す
517+
if merged_ranges:
518+
sheet_data["merged_ranges"] = merged_ranges
519+
520+
# データ取得
521+
if cell_range:
522+
# ヘッダー自動追加(include_frozen_rows=Trueの場合)
523+
# header_rangeは既に計算済みなので再利用
524+
if header_range:
525+
# ヘッダー範囲を取得
526+
header_data = sheet[header_range]
527+
header_rows = self._normalize_range_data(header_data)
528+
all_rows.extend(
529+
self._parse_rows(
530+
header_rows,
531+
merged_cell_map,
532+
merged_anchor_value_map,
533+
)
534+
)
535+
536+
# 通常のセル範囲取得(データ範囲)
537+
range_data = sheet[effective_range]
538+
rows_to_process = self._normalize_range_data(range_data)
539+
all_rows.extend(
540+
self._parse_rows(
541+
rows_to_process,
542+
merged_cell_map,
543+
merged_anchor_value_map,
544+
)
545+
)
546+
547+
elif sheet.dimensions:
414548
# 全データを取得
415549
rows_to_process = tuple(sheet.iter_rows())
416550

tests/test_server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ def test_excel_read_default(
224224
"/sites/test/Shared Documents/test.xlsx",
225225
sheet_name=None,
226226
cell_range=None,
227+
include_frozen_rows=True,
227228
)
228229

229230
@pytest.mark.unit
@@ -263,6 +264,7 @@ def test_excel_with_sheet_parameter(
263264
"/sites/test/Shared Documents/test.xlsx",
264265
sheet_name="Sheet2",
265266
cell_range=None,
267+
include_frozen_rows=True,
266268
)
267269

268270
@pytest.mark.unit
@@ -284,6 +286,7 @@ def test_excel_with_cell_range_parameter(
284286
"/sites/test/Shared Documents/test.xlsx",
285287
sheet_name="Sheet1",
286288
cell_range="A1:D10",
289+
include_frozen_rows=True,
287290
)
288291

289292
@pytest.mark.unit

0 commit comments

Comments
 (0)