diff --git a/gsheets/sheets_helpers.py b/gsheets/sheets_helpers.py index 413c357..c4c128f 100644 --- a/gsheets/sheets_helpers.py +++ b/gsheets/sheets_helpers.py @@ -877,3 +877,81 @@ def _build_gradient_rule( rule_body["gradientRule"]["midpoint"] = gradient_points[1] rule_body["gradientRule"]["maxpoint"] = gradient_points[2] return rule_body + + +def _extract_cell_notes_from_grid(spreadsheet: dict) -> list[dict[str, str]]: + """ + Extract cell notes from spreadsheet grid data. + + Returns a list of dictionaries with: + - "cell": cell A1 reference + - "note": the note text + """ + notes: list[dict[str, str]] = [] + for sheet in spreadsheet.get("sheets", []) or []: + sheet_title = sheet.get("properties", {}).get("title") or "Unknown" + for grid in sheet.get("data", []) or []: + start_row = _coerce_int(grid.get("startRow"), default=0) + start_col = _coerce_int(grid.get("startColumn"), default=0) + for row_offset, row_data in enumerate(grid.get("rowData", []) or []): + if not row_data: + continue + for col_offset, cell_data in enumerate( + row_data.get("values", []) or [] + ): + if not cell_data: + continue + note = cell_data.get("note") + if not note: + continue + notes.append( + { + "cell": _format_a1_cell( + sheet_title, + start_row + row_offset, + start_col + col_offset, + ), + "note": note, + } + ) + return notes + + +async def _fetch_sheet_notes( + service, spreadsheet_id: str, a1_range: str +) -> list[dict[str, str]]: + """Fetch cell notes for the given range via spreadsheets.get with includeGridData.""" + response = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + ranges=[a1_range], + includeGridData=True, + fields="sheets(properties(title),data(startRow,startColumn,rowData(values(note))))", + ) + .execute + ) + return _extract_cell_notes_from_grid(response) + + +def _format_sheet_notes_section( + *, notes: list[dict[str, str]], range_label: str, max_details: int = 25 +) -> str: + """ + Format a list of cell notes into a human-readable section. + """ + if not notes: + return "" + + lines = [] + for item in notes[:max_details]: + cell = item.get("cell") or "(unknown cell)" + note = item.get("note") or "(empty note)" + lines.append(f"- {cell}: {note}") + + suffix = ( + f"\n... and {len(notes) - max_details} more notes" + if len(notes) > max_details + else "" + ) + return f"\n\nCell notes in range '{range_label}':\n" + "\n".join(lines) + suffix diff --git a/gsheets/sheets_tools.py b/gsheets/sheets_tools.py index 04676f9..5f6da4e 100644 --- a/gsheets/sheets_tools.py +++ b/gsheets/sheets_tools.py @@ -22,9 +22,11 @@ from gsheets.sheets_helpers import ( _build_gradient_rule, _fetch_detailed_sheet_errors, _fetch_sheet_hyperlinks, + _fetch_sheet_notes, _fetch_sheets_with_rules, _format_conditional_rules_section, _format_sheet_hyperlink_section, + _format_sheet_notes_section, _format_sheet_error_section, _parse_a1_range, _parse_condition_values, @@ -179,6 +181,7 @@ async def read_sheet_values( spreadsheet_id: str, range_name: str = "A1:Z1000", include_hyperlinks: bool = False, + include_notes: bool = False, ) -> str: """ Reads values from a specific range in a Google Sheet. @@ -189,6 +192,8 @@ async def read_sheet_values( range_name (str): The range to read (e.g., "Sheet1!A1:D10", "A1:D10"). Defaults to "A1:Z1000". include_hyperlinks (bool): If True, also fetch hyperlink metadata for the range. Defaults to False to avoid expensive includeGridData requests. + include_notes (bool): If True, also fetch cell notes for the range. + Defaults to False to avoid expensive includeGridData requests. Returns: str: The formatted values from the specified range. @@ -247,6 +252,40 @@ async def read_sheet_values( MAX_HYPERLINK_FETCH_CELLS, ) + notes_section = "" + if include_notes: + notes_range = _a1_range_for_values(resolved_range, values) + if not notes_range: + logger.info( + "[read_sheet_values] Skipping notes fetch for range '%s': unable to determine tight bounds", + resolved_range, + ) + else: + cell_count = _a1_range_cell_count(notes_range) or sum( + len(row) for row in values + ) + if cell_count <= MAX_HYPERLINK_FETCH_CELLS: + try: + notes = await _fetch_sheet_notes( + service, spreadsheet_id, notes_range + ) + notes_section = _format_sheet_notes_section( + notes=notes, range_label=notes_range + ) + except Exception as exc: + logger.warning( + "[read_sheet_values] Failed fetching notes for range '%s': %s", + notes_range, + exc, + ) + else: + logger.info( + "[read_sheet_values] Skipping notes fetch for large range '%s' (%d cells > %d limit)", + notes_range, + cell_count, + MAX_HYPERLINK_FETCH_CELLS, + ) + detailed_errors_section = "" if _values_contain_sheets_errors(values): try: @@ -277,7 +316,7 @@ async def read_sheet_values( ) logger.info(f"Successfully read {len(values)} rows for {user_google_email}.") - return text_output + hyperlink_section + detailed_errors_section + return text_output + hyperlink_section + notes_section + detailed_errors_section @server.tool()