Files
AutoGPT/autogpt_platform/backend/backend/blocks/google/sheets.py
Reinier van der Leer fe9debd80f refactor(backend/blocks): Extract backend.blocks._base from backend.data.block
I'm getting circular import issues because there is a lot of cross-importing between `backend.data`, `backend.blocks`, and other components. This change reduces block-related cross-imports and thus risk of breaking circular imports.
2026-02-11 15:38:42 +01:00

6532 lines
248 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import csv
import io
import re
from enum import Enum
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from backend.blocks._base import (
Block,
BlockCategory,
BlockOutput,
BlockSchemaInput,
BlockSchemaOutput,
)
from backend.blocks.google._drive import GoogleDriveFile, GoogleDriveFileField
from backend.data.model import SchemaField
from backend.util.settings import Settings
from ._auth import (
GOOGLE_OAUTH_IS_CONFIGURED,
TEST_CREDENTIALS,
TEST_CREDENTIALS_INPUT,
GoogleCredentials,
GoogleCredentialsField,
GoogleCredentialsInput,
)
settings = Settings()
GOOGLE_SHEETS_DISABLED = not GOOGLE_OAUTH_IS_CONFIGURED
def parse_a1_notation(a1: str) -> tuple[str | None, str]:
"""Split an A1notation string into *(sheet_name, cell_range)*.
Examples
--------
>>> parse_a1_notation("Sheet1!A1:B2")
("Sheet1", "A1:B2")
>>> parse_a1_notation("A1:B2")
(None, "A1:B2")
"""
if "!" in a1:
sheet, cell_range = a1.split("!", 1)
return sheet, cell_range
return None, a1
def extract_spreadsheet_id(spreadsheet_id_or_url: str) -> str:
"""Extract spreadsheet ID from either a direct ID or a Google Sheets URL.
Examples
--------
>>> extract_spreadsheet_id("1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms")
"1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
>>> extract_spreadsheet_id("https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit")
"1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
"""
if "/spreadsheets/d/" in spreadsheet_id_or_url:
# Extract ID from URL: https://docs.google.com/spreadsheets/d/{ID}/edit...
parts = spreadsheet_id_or_url.split("/d/")[1].split("/")[0]
return parts
return spreadsheet_id_or_url
def format_sheet_name(sheet_name: str) -> str:
"""Format sheet name for Google Sheets API, adding quotes if needed.
Examples
--------
>>> format_sheet_name("Sheet1")
"Sheet1"
>>> format_sheet_name("Non-matching Leads")
"'Non-matching Leads'"
"""
# If sheet name contains spaces, special characters, or starts with a digit, wrap in quotes
if (
" " in sheet_name
or any(char in sheet_name for char in "!@#$%^&*()+-=[]{}|;:,.<>?")
or (sheet_name and sheet_name[0].isdigit())
):
return f"'{sheet_name}'"
return sheet_name
def _first_sheet_meta(service, spreadsheet_id: str) -> tuple[str, int]:
"""Return *(title, sheetId)* for the first sheet in *spreadsheet_id*."""
meta = (
service.spreadsheets()
.get(spreadsheetId=spreadsheet_id, includeGridData=False)
.execute()
)
first = meta["sheets"][0]["properties"]
return first["title"], first["sheetId"]
def get_all_sheet_names(service, spreadsheet_id: str) -> list[str]:
"""Get all sheet names in the spreadsheet."""
meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
return [
sheet.get("properties", {}).get("title", "") for sheet in meta.get("sheets", [])
]
def resolve_sheet_name(service, spreadsheet_id: str, sheet_name: str | None) -> str:
"""Resolve *sheet_name*, falling back to the workbook's first sheet if empty.
Validates that the sheet exists in the spreadsheet and provides helpful error info.
"""
if sheet_name:
# Validate that the sheet exists
all_sheets = get_all_sheet_names(service, spreadsheet_id)
if sheet_name not in all_sheets:
raise ValueError(
f'Sheet "{sheet_name}" not found in spreadsheet. '
f"Available sheets: {all_sheets}"
)
return sheet_name
title, _ = _first_sheet_meta(service, spreadsheet_id)
return title
def sheet_id_by_name(service, spreadsheet_id: str, sheet_name: str) -> int | None:
"""Return the *sheetId* for *sheet_name* (or `None` if not found)."""
meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
for sh in meta.get("sheets", []):
if sh.get("properties", {}).get("title") == sheet_name:
return sh["properties"]["sheetId"]
return None
def _build_sheets_service(credentials: GoogleCredentials):
"""Build Sheets service from platform credentials (with refresh token)."""
settings = Settings()
creds = Credentials(
token=(
credentials.access_token.get_secret_value()
if credentials.access_token
else None
),
refresh_token=(
credentials.refresh_token.get_secret_value()
if credentials.refresh_token
else None
),
token_uri="https://oauth2.googleapis.com/token",
client_id=settings.secrets.google_client_id,
client_secret=settings.secrets.google_client_secret,
scopes=credentials.scopes,
)
return build("sheets", "v4", credentials=creds)
def _build_drive_service(credentials: GoogleCredentials):
"""Build Drive service from platform credentials (with refresh token)."""
settings = Settings()
creds = Credentials(
token=(
credentials.access_token.get_secret_value()
if credentials.access_token
else None
),
refresh_token=(
credentials.refresh_token.get_secret_value()
if credentials.refresh_token
else None
),
token_uri="https://oauth2.googleapis.com/token",
client_id=settings.secrets.google_client_id,
client_secret=settings.secrets.google_client_secret,
scopes=credentials.scopes,
)
return build("drive", "v3", credentials=creds)
def _validate_spreadsheet_file(spreadsheet_file: "GoogleDriveFile") -> str | None:
"""Validate that the selected file is a Google Sheets spreadsheet.
Returns None if valid, error message string if invalid.
"""
if spreadsheet_file.mime_type != "application/vnd.google-apps.spreadsheet":
file_type = spreadsheet_file.mime_type
file_name = spreadsheet_file.name
if file_type == "text/csv":
return f"Cannot use CSV file '{file_name}' with Google Sheets block. Please use a CSV reader block instead, or convert the CSV to a Google Sheets spreadsheet first."
elif file_type in [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]:
return f"Cannot use Excel file '{file_name}' with Google Sheets block. Please use an Excel reader block instead, or convert to Google Sheets first."
else:
return f"Cannot use file '{file_name}' (type: {file_type}) with Google Sheets block. This block only works with Google Sheets spreadsheets."
return None
def _handle_sheets_api_error(error_msg: str, operation: str = "access") -> str:
"""Convert common Google Sheets API errors to user-friendly messages."""
if "Request contains an invalid argument" in error_msg:
return f"Invalid request to Google Sheets API. This usually means the file is not a Google Sheets spreadsheet, the range is invalid, or you don't have permission to {operation} this file."
elif "The caller does not have permission" in error_msg or "Forbidden" in error_msg:
if operation in ["write", "modify", "update", "append", "clear"]:
return "Permission denied. You don't have edit access to this spreadsheet. Make sure it's shared with edit permissions."
else:
return "Permission denied. You don't have access to this spreadsheet. Make sure it's shared with you and try re-selecting the file."
elif "not found" in error_msg.lower() or "does not exist" in error_msg.lower():
return "Spreadsheet not found. The file may have been deleted or the link is invalid."
else:
return f"Failed to {operation} Google Sheet: {error_msg}"
class SheetOperation(str, Enum):
CREATE = "create"
DELETE = "delete"
COPY = "copy"
class ValueInputOption(str, Enum):
RAW = "RAW"
USER_ENTERED = "USER_ENTERED"
class InsertDataOption(str, Enum):
OVERWRITE = "OVERWRITE"
INSERT_ROWS = "INSERT_ROWS"
class BatchOperationType(str, Enum):
UPDATE = "update"
CLEAR = "clear"
class PublicAccessRole(str, Enum):
READER = "reader"
COMMENTER = "commenter"
class ShareRole(str, Enum):
READER = "reader"
WRITER = "writer"
COMMENTER = "commenter"
class BatchOperation(BlockSchemaInput):
type: BatchOperationType = SchemaField(
description="The type of operation to perform"
)
range: str = SchemaField(description="The A1 notation range for the operation")
values: list[list[str]] = SchemaField(
description="Values to update (only for UPDATE)", default=[]
)
class GoogleSheetsReadBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
range: str = SchemaField(
description="The A1 notation of the range to read",
placeholder="Sheet1!A1:Z1000",
)
class Output(BlockSchemaOutput):
result: list[list[str]] = SchemaField(
description="The data read from the spreadsheet",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="5724e902-3635-47e9-a108-aaa0263a4988",
description="A block that reads data from a Google Sheets spreadsheet using A1 notation range selection.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsReadBlock.Input,
output_schema=GoogleSheetsReadBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"range": "Sheet1!A1:B2",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"result",
[
["Name", "Score"],
["Alice", "85"],
],
),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_read_sheet": lambda *args, **kwargs: [
["Name", "Score"],
["Alice", "85"],
],
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
spreadsheet_id = input_data.spreadsheet.id
data = await asyncio.to_thread(
self._read_sheet, service, spreadsheet_id, input_data.range
)
yield "result", data
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=spreadsheet_id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", _handle_sheets_api_error(str(e), "read")
def _read_sheet(self, service, spreadsheet_id: str, range: str) -> list[list[str]]:
sheet = service.spreadsheets()
range_to_use = range or "A:Z"
sheet_name, cell_range = parse_a1_notation(range_to_use)
if sheet_name:
cleaned_sheet = sheet_name.strip().strip("'\"")
formatted_sheet = format_sheet_name(cleaned_sheet)
cell_part = cell_range.strip() if cell_range else ""
if cell_part:
range_to_use = f"{formatted_sheet}!{cell_part}"
else:
range_to_use = f"{formatted_sheet}!A:Z"
# If no sheet name, keep the original range (e.g., "A1:B2" or "B:B")
result = (
sheet.values()
.get(spreadsheetId=spreadsheet_id, range=range_to_use)
.execute()
)
return result.get("values", [])
class GoogleSheetsWriteBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
range: str = SchemaField(
description="The A1 notation of the range to write",
placeholder="Sheet1!A1:B2",
)
values: list[list[str]] = SchemaField(
description="The data to write to the spreadsheet",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result of the write operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="d9291e87-301d-47a8-91fe-907fb55460e5",
description="A block that writes data to a Google Sheets spreadsheet at a specified A1 notation range.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsWriteBlock.Input,
output_schema=GoogleSheetsWriteBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"range": "Sheet1!A1:B2",
"values": [
["Name", "Score"],
["Bob", "90"],
],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"result",
{"updatedCells": 4, "updatedColumns": 2, "updatedRows": 2},
),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_write_sheet": lambda *args, **kwargs: {
"updatedCells": 4,
"updatedColumns": 2,
"updatedRows": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
# Customize message for write operations on CSV files
if "CSV file" in validation_error:
yield "error", validation_error.replace(
"Please use a CSV reader block instead, or",
"CSV files are read-only through Google Drive. Please",
)
else:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._write_sheet,
service,
input_data.spreadsheet.id,
input_data.range,
input_data.values,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", _handle_sheets_api_error(str(e), "write")
def _write_sheet(
self, service, spreadsheet_id: str, range: str, values: list[list[str]]
) -> dict:
body = {"values": values}
result = (
service.spreadsheets()
.values()
.update(
spreadsheetId=spreadsheet_id,
range=range,
valueInputOption="USER_ENTERED",
body=body,
)
.execute()
)
return result
class GoogleSheetsAppendRowBlock(Block):
"""Append a single row to the end of a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
row: list[str] = SchemaField(
description="Row values to append (e.g., ['Alice', 'alice@example.com', '25'])",
)
sheet_name: str = SchemaField(
description="Sheet to append to (optional, defaults to first sheet)",
default="",
)
value_input_option: ValueInputOption = SchemaField(
description="How values are interpreted. USER_ENTERED: parsed like typed input (e.g., '=SUM(A1:A5)' becomes a formula, '1/2/2024' becomes a date). RAW: stored as-is without parsing.",
default=ValueInputOption.USER_ENTERED,
advanced=True,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Append API response")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining to other blocks",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="531d50c0-d6b9-4cf9-a013-7bf783d313c7",
description="Append or Add a single row to the end of a Google Sheet. The row is added after the last row with data.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsAppendRowBlock.Input,
output_schema=GoogleSheetsAppendRowBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"row": ["Charlie", "95"],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"updatedCells": 2, "updatedColumns": 2, "updatedRows": 1}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_append_row": lambda *args, **kwargs: {
"updatedCells": 2,
"updatedColumns": 2,
"updatedRows": 1,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
if not input_data.row:
yield "error", "Row data is required"
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._append_row,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.row,
input_data.value_input_option,
)
yield "result", result
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to append row: {str(e)}"
def _append_row(
self,
service,
spreadsheet_id: str,
sheet_name: str,
row: list[str],
value_input_option: ValueInputOption,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
append_range = f"{formatted_sheet}!A1"
body = {"values": [row]} # Wrap single row in list for API
result = (
service.spreadsheets()
.values()
.append(
spreadsheetId=spreadsheet_id,
range=append_range,
valueInputOption=value_input_option.value,
insertDataOption="INSERT_ROWS",
body=body,
)
.execute()
)
return {
"updatedCells": result.get("updates", {}).get("updatedCells", 0),
"updatedRows": result.get("updates", {}).get("updatedRows", 0),
"updatedColumns": result.get("updates", {}).get("updatedColumns", 0),
}
class GoogleSheetsClearBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
range: str = SchemaField(
description="The A1 notation of the range to clear",
placeholder="Sheet1!A1:B2",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result of the clear operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="84938266-0fc7-46e5-9369-adb0f6ae8015",
description="This block clears data from a specified range in a Google Sheets spreadsheet.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsClearBlock.Input,
output_schema=GoogleSheetsClearBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"range": "Sheet1!A1:B2",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"clearedRange": "Sheet1!A1:B2"}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_clear_range": lambda *args, **kwargs: {
"clearedRange": "Sheet1!A1:B2"
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._clear_range,
service,
input_data.spreadsheet.id,
input_data.range,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to clear Google Sheet range: {str(e)}"
def _clear_range(self, service, spreadsheet_id: str, range: str) -> dict:
result = (
service.spreadsheets()
.values()
.clear(spreadsheetId=spreadsheet_id, range=range)
.execute()
)
return result
class GoogleSheetsMetadataBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The metadata of the spreadsheet including sheets info",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="6a0be6ee-7a0d-4c92-819b-500630846ad0",
description="This block retrieves metadata about a Google Sheets spreadsheet including sheet names and properties.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsMetadataBlock.Input,
output_schema=GoogleSheetsMetadataBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"result",
{
"title": "Test Spreadsheet",
"sheets": [{"title": "Sheet1", "sheetId": 0}],
},
),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_get_metadata": lambda *args, **kwargs: {
"title": "Test Spreadsheet",
"sheets": [{"title": "Sheet1", "sheetId": 0}],
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._get_metadata,
service,
input_data.spreadsheet.id,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to get spreadsheet metadata: {str(e)}"
def _get_metadata(self, service, spreadsheet_id: str) -> dict:
result = (
service.spreadsheets()
.get(spreadsheetId=spreadsheet_id, includeGridData=False)
.execute()
)
return {
"title": result.get("properties", {}).get("title"),
"sheets": [
{
"title": sheet.get("properties", {}).get("title"),
"sheetId": sheet.get("properties", {}).get("sheetId"),
"gridProperties": sheet.get("properties", {}).get(
"gridProperties", {}
),
}
for sheet in result.get("sheets", [])
],
}
class GoogleSheetsManageSheetBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
operation: SheetOperation = SchemaField(description="Operation to perform")
sheet_name: str = SchemaField(
description="Target sheet name (defaults to first sheet for delete)",
default="",
)
source_sheet_id: int = SchemaField(
description="Source sheet ID for copy", default=0
)
destination_sheet_name: str = SchemaField(
description="New sheet name for copy", default=""
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Operation result")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="7940189d-b137-4ef1-aa18-3dd9a5bde9f3",
description="Create, delete, or copy sheets (sheet optional)",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsManageSheetBlock.Input,
output_schema=GoogleSheetsManageSheetBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"operation": SheetOperation.CREATE,
"sheet_name": "NewSheet",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True, "sheetId": 123}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_manage_sheet": lambda *args, **kwargs: {
"success": True,
"sheetId": 123,
}
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._manage_sheet,
service,
input_data.spreadsheet.id,
input_data.operation,
input_data.sheet_name,
input_data.source_sheet_id,
input_data.destination_sheet_name,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to manage sheet: {str(e)}"
def _manage_sheet(
self,
service,
spreadsheet_id: str,
operation: SheetOperation,
sheet_name: str,
source_sheet_id: int,
destination_sheet_name: str,
) -> dict:
requests = []
if operation == SheetOperation.CREATE:
# For CREATE, use sheet_name directly or default to "New Sheet"
target_name = sheet_name or "New Sheet"
requests.append({"addSheet": {"properties": {"title": target_name}}})
elif operation == SheetOperation.DELETE:
# For DELETE, resolve sheet name (fall back to first sheet if empty)
target_name = resolve_sheet_name(
service, spreadsheet_id, sheet_name or None
)
sid = sheet_id_by_name(service, spreadsheet_id, target_name)
if sid is None:
return {"error": f"Sheet '{target_name}' not found"}
requests.append({"deleteSheet": {"sheetId": sid}})
elif operation == SheetOperation.COPY:
# For COPY, use source_sheet_id and destination_sheet_name directly
requests.append(
{
"duplicateSheet": {
"sourceSheetId": source_sheet_id,
"newSheetName": destination_sheet_name
or f"Copy of {source_sheet_id}",
}
}
)
else:
return {"error": f"Unknown operation: {operation}"}
body = {"requests": requests}
result = (
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=body)
.execute()
)
return {"success": True, "result": result}
class GoogleSheetsBatchOperationsBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
operations: list[BatchOperation] = SchemaField(
description="List of operations to perform",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result of the batch operations",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="a4078584-6fe5-46e0-997e-d5126cdd112a",
description="This block performs multiple operations on a Google Sheets spreadsheet in a single batch request.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsBatchOperationsBlock.Input,
output_schema=GoogleSheetsBatchOperationsBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"operations": [
{
"type": BatchOperationType.UPDATE,
"range": "A1:B1",
"values": [["Header1", "Header2"]],
},
{
"type": BatchOperationType.UPDATE,
"range": "A2:B2",
"values": [["Data1", "Data2"]],
},
],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"totalUpdatedCells": 4, "replies": []}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_batch_operations": lambda *args, **kwargs: {
"totalUpdatedCells": 4,
"replies": [],
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._batch_operations,
service,
input_data.spreadsheet.id,
input_data.operations,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to perform batch operations: {str(e)}"
def _batch_operations(
self, service, spreadsheet_id: str, operations: list[BatchOperation]
) -> dict:
update_data = []
clear_ranges = []
for op in operations:
if op.type == BatchOperationType.UPDATE:
update_data.append(
{
"range": op.range,
"values": op.values,
}
)
elif op.type == BatchOperationType.CLEAR:
clear_ranges.append(op.range)
results = {}
# Perform updates if any
if update_data:
update_body = {
"valueInputOption": "USER_ENTERED",
"data": update_data,
}
update_result = (
service.spreadsheets()
.values()
.batchUpdate(spreadsheetId=spreadsheet_id, body=update_body)
.execute()
)
results["updateResult"] = update_result
# Perform clears if any
if clear_ranges:
clear_body = {"ranges": clear_ranges}
clear_result = (
service.spreadsheets()
.values()
.batchClear(spreadsheetId=spreadsheet_id, body=clear_body)
.execute()
)
results["clearResult"] = clear_result
return results
class GoogleSheetsFindReplaceBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
find_text: str = SchemaField(
description="The text to find",
)
replace_text: str = SchemaField(
description="The text to replace with",
)
sheet_id: int = SchemaField(
description="The ID of the specific sheet to search (optional, searches all sheets if not provided)",
default=-1,
)
match_case: bool = SchemaField(
description="Whether to match case",
default=False,
)
match_entire_cell: bool = SchemaField(
description="Whether to match entire cell",
default=False,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result of the find/replace operation including number of replacements",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="accca760-8174-4656-b55e-5f0e82fee986",
description="This block finds and replaces text in a Google Sheets spreadsheet.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsFindReplaceBlock.Input,
output_schema=GoogleSheetsFindReplaceBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"find_text": "old_value",
"replace_text": "new_value",
"match_case": False,
"match_entire_cell": False,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"occurrencesChanged": 5}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_find_replace": lambda *args, **kwargs: {"occurrencesChanged": 5},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._find_replace,
service,
input_data.spreadsheet.id,
input_data.find_text,
input_data.replace_text,
input_data.sheet_id,
input_data.match_case,
input_data.match_entire_cell,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to find/replace in Google Sheet: {str(e)}"
def _find_replace(
self,
service,
spreadsheet_id: str,
find_text: str,
replace_text: str,
sheet_id: int,
match_case: bool,
match_entire_cell: bool,
) -> dict:
find_replace_request = {
"find": find_text,
"replacement": replace_text,
"matchCase": match_case,
"matchEntireCell": match_entire_cell,
}
if sheet_id >= 0:
find_replace_request["sheetId"] = sheet_id
requests = [{"findReplace": find_replace_request}]
body = {"requests": requests}
result = (
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=body)
.execute()
)
return result
class GoogleSheetsFindBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
find_text: str = SchemaField(
description="The text to find",
)
sheet_id: int = SchemaField(
description="The ID of the specific sheet to search (optional, searches all sheets if not provided)",
default=-1,
)
match_case: bool = SchemaField(
description="Whether to match case",
default=False,
)
match_entire_cell: bool = SchemaField(
description="Whether to match entire cell",
default=False,
)
find_all: bool = SchemaField(
description="Whether to find all occurrences (true) or just the first one (false)",
default=True,
)
range: str = SchemaField(
description="The A1 notation range to search in (optional, searches entire sheet if not provided)",
default="",
advanced=True,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result of the find operation including locations and count",
)
locations: list[dict] = SchemaField(
description="List of cell locations where the text was found",
)
count: int = SchemaField(
description="Number of occurrences found",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="0f4ecc72-b958-47b2-b65e-76d6d26b9b27",
description="Find text in a Google Sheets spreadsheet. Returns locations and count of occurrences. Can find all occurrences or just the first one.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsFindBlock.Input,
output_schema=GoogleSheetsFindBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"find_text": "search_value",
"match_case": False,
"match_entire_cell": False,
"find_all": True,
"range": "Sheet1!A1:C10",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("count", 3),
(
"locations",
[
{"sheet": "Sheet1", "row": 2, "column": 1, "address": "A2"},
{"sheet": "Sheet1", "row": 5, "column": 3, "address": "C5"},
{"sheet": "Sheet2", "row": 1, "column": 2, "address": "B1"},
],
),
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_find_text": lambda *args, **kwargs: {
"locations": [
{"sheet": "Sheet1", "row": 2, "column": 1, "address": "A2"},
{"sheet": "Sheet1", "row": 5, "column": 3, "address": "C5"},
{"sheet": "Sheet2", "row": 1, "column": 2, "address": "B1"},
],
"count": 3,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._find_text,
service,
input_data.spreadsheet.id,
input_data.find_text,
input_data.sheet_id,
input_data.match_case,
input_data.match_entire_cell,
input_data.find_all,
input_data.range,
)
yield "count", result["count"]
yield "locations", result["locations"]
yield "result", {"success": True}
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to find text in Google Sheet: {str(e)}"
def _find_text(
self,
service,
spreadsheet_id: str,
find_text: str,
sheet_id: int,
match_case: bool,
match_entire_cell: bool,
find_all: bool,
range: str,
) -> dict:
# Unfortunately, Google Sheets API doesn't have a dedicated "find-only" operation
# that returns cell locations. The findReplace operation only returns a count.
# So we need to search through the values manually to get location details.
locations = []
search_range = range if range else None
if not search_range:
# If no range specified, search entire spreadsheet
meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
sheets = meta.get("sheets", [])
# Filter to specific sheet if provided
if sheet_id >= 0:
sheets = [
s
for s in sheets
if s.get("properties", {}).get("sheetId") == sheet_id
]
# Search each sheet
for sheet in sheets:
sheet_name = sheet.get("properties", {}).get("title", "")
sheet_range = f"'{sheet_name}'"
self._search_range(
service,
spreadsheet_id,
sheet_range,
sheet_name,
find_text,
match_case,
match_entire_cell,
find_all,
locations,
)
if not find_all and locations:
break
else:
# Search specific range
sheet_name, cell_range = parse_a1_notation(search_range)
if not sheet_name:
# Get first sheet name if not specified
meta = (
service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
)
sheet_name = (
meta.get("sheets", [{}])[0]
.get("properties", {})
.get("title", "Sheet1")
)
search_range = f"'{sheet_name}'!{search_range}"
self._search_range(
service,
spreadsheet_id,
search_range,
sheet_name,
find_text,
match_case,
match_entire_cell,
find_all,
locations,
)
return {"locations": locations, "count": len(locations)}
def _search_range(
self,
service,
spreadsheet_id: str,
range_name: str,
sheet_name: str,
find_text: str,
match_case: bool,
match_entire_cell: bool,
find_all: bool,
locations: list,
):
"""Search within a specific range and add results to locations list."""
values_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=range_name)
.execute()
)
values = values_result.get("values", [])
# Parse range to get starting position
_, cell_range = parse_a1_notation(range_name)
start_col = 0
start_row = 0
if cell_range and ":" in cell_range:
start_cell = cell_range.split(":")[0]
# Parse A1 notation (e.g., "B3" -> col=1, row=2)
col_part = ""
row_part = ""
for char in start_cell:
if char.isalpha():
col_part += char
elif char.isdigit():
row_part += char
if col_part:
start_col = ord(col_part.upper()) - ord("A")
if row_part:
start_row = int(row_part) - 1
# Search through values
for row_idx, row in enumerate(values):
for col_idx, cell_value in enumerate(row):
if cell_value is None:
continue
cell_str = str(cell_value)
# Apply search criteria
search_text = find_text if match_case else find_text.lower()
cell_text = cell_str if match_case else cell_str.lower()
found = False
if match_entire_cell:
found = cell_text == search_text
else:
found = search_text in cell_text
if found:
# Calculate actual spreadsheet position
actual_row = start_row + row_idx + 1
actual_col = start_col + col_idx + 1
col_letter = chr(ord("A") + start_col + col_idx)
address = f"{col_letter}{actual_row}"
location = {
"sheet": sheet_name,
"row": actual_row,
"column": actual_col,
"address": address,
"value": cell_str,
}
locations.append(location)
# Stop after first match if find_all is False
if not find_all:
return
class GoogleSheetsFormatBlock(Block):
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
range: str = SchemaField(
description="A1 notation sheet optional",
placeholder="Sheet1!A1:B2",
)
background_color: dict = SchemaField(default={})
text_color: dict = SchemaField(default={})
bold: bool = SchemaField(default=False)
italic: bool = SchemaField(default=False)
font_size: int = SchemaField(default=10)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="API response or success flag")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="270f2384-8089-4b5b-b2e3-fe2ea3d87c02",
description="Format a range in a Google Sheet (sheet optional)",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsFormatBlock.Input,
output_schema=GoogleSheetsFormatBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"range": "A1:B2",
"background_color": {"red": 1.0, "green": 0.9, "blue": 0.9},
"bold": True,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={"_format_cells": lambda *args, **kwargs: {"success": True}},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._format_cells,
service,
input_data.spreadsheet.id,
input_data.range,
input_data.background_color,
input_data.text_color,
input_data.bold,
input_data.italic,
input_data.font_size,
)
if "error" in result:
yield "error", result["error"]
else:
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to format Google Sheet cells: {str(e)}"
def _format_cells(
self,
service,
spreadsheet_id: str,
a1_range: str,
background_color: dict,
text_color: dict,
bold: bool,
italic: bool,
font_size: int,
) -> dict:
sheet_name, cell_range = parse_a1_notation(a1_range)
sheet_name = resolve_sheet_name(service, spreadsheet_id, sheet_name)
sheet_id = sheet_id_by_name(service, spreadsheet_id, sheet_name)
if sheet_id is None:
return {"error": f"Sheet '{sheet_name}' not found"}
try:
start_cell, end_cell = cell_range.split(":")
start_col = ord(start_cell[0].upper()) - ord("A")
start_row = int(start_cell[1:]) - 1
end_col = ord(end_cell[0].upper()) - ord("A") + 1
end_row = int(end_cell[1:])
except (ValueError, IndexError):
return {"error": f"Invalid range format: {a1_range}"}
cell_format: dict = {"userEnteredFormat": {}}
if background_color:
cell_format["userEnteredFormat"]["backgroundColor"] = background_color
text_format: dict = {}
if text_color:
text_format["foregroundColor"] = text_color
if bold:
text_format["bold"] = True
if italic:
text_format["italic"] = True
if font_size != 10:
text_format["fontSize"] = font_size
if text_format:
cell_format["userEnteredFormat"]["textFormat"] = text_format
body = {
"requests": [
{
"repeatCell": {
"range": {
"sheetId": sheet_id,
"startRowIndex": start_row,
"endRowIndex": end_row,
"startColumnIndex": start_col,
"endColumnIndex": end_col,
},
"cell": cell_format,
"fields": "userEnteredFormat(backgroundColor,textFormat)",
}
}
]
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body=body
).execute()
return {"success": True}
class GoogleSheetsCreateSpreadsheetBlock(Block):
class Input(BlockSchemaInput):
# Explicit credentials since this block creates a file (no file picker)
credentials: GoogleCredentialsInput = GoogleCredentialsField(
["https://www.googleapis.com/auth/drive.file"]
)
title: str = SchemaField(
description="The title of the new spreadsheet",
)
sheet_names: list[str] = SchemaField(
description="List of sheet names to create (optional, defaults to single 'Sheet1')",
default=["Sheet1"],
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result containing spreadsheet ID and URL",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The created spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
spreadsheet_id: str = SchemaField(
description="The ID of the created spreadsheet",
)
spreadsheet_url: str = SchemaField(
description="The URL of the created spreadsheet",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="c8d4c0d3-c76e-4c2a-8c66-4119817ea3d1",
description="This block creates a new Google Sheets spreadsheet with specified sheets.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsCreateSpreadsheetBlock.Input,
output_schema=GoogleSheetsCreateSpreadsheetBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"title": "Test Spreadsheet",
"sheet_names": ["Sheet1", "Data", "Summary"],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=TEST_CREDENTIALS_INPUT[
"id"
], # Preserves credential ID for chaining
),
),
("spreadsheet_id", "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"),
(
"spreadsheet_url",
"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
),
("result", {"success": True}),
],
test_mock={
"_create_spreadsheet": lambda *args, **kwargs: {
"spreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
"title": "Test Spreadsheet",
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
drive_service = _build_drive_service(credentials)
sheets_service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._create_spreadsheet,
drive_service,
sheets_service,
input_data.title,
input_data.sheet_names,
)
if "error" in result:
yield "error", result["error"]
else:
spreadsheet_id = result["spreadsheetId"]
spreadsheet_url = result["spreadsheetUrl"]
# Output the GoogleDriveFile for chaining (includes credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=spreadsheet_id,
name=result.get("title", input_data.title),
mimeType="application/vnd.google-apps.spreadsheet",
url=spreadsheet_url,
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.credentials.id, # Preserve credentials for chaining
)
yield "spreadsheet_id", spreadsheet_id
yield "spreadsheet_url", spreadsheet_url
yield "result", {"success": True}
def _create_spreadsheet(
self, drive_service, sheets_service, title: str, sheet_names: list[str]
) -> dict:
try:
# Create blank spreadsheet using Drive API
file_metadata = {
"name": title,
"mimeType": "application/vnd.google-apps.spreadsheet",
}
result = (
drive_service.files()
.create(body=file_metadata, fields="id, webViewLink")
.execute()
)
spreadsheet_id = result["id"]
spreadsheet_url = result.get(
"webViewLink",
f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit",
)
# Rename first sheet if custom name provided (default is "Sheet1")
if sheet_names and sheet_names[0] != "Sheet1":
# Get first sheet ID and rename it
meta = (
sheets_service.spreadsheets()
.get(spreadsheetId=spreadsheet_id)
.execute()
)
first_sheet_id = meta["sheets"][0]["properties"]["sheetId"]
sheets_service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id,
body={
"requests": [
{
"updateSheetProperties": {
"properties": {
"sheetId": first_sheet_id,
"title": sheet_names[0],
},
"fields": "title",
}
}
]
},
).execute()
# Add additional sheets if requested
if len(sheet_names) > 1:
requests = [
{"addSheet": {"properties": {"title": name}}}
for name in sheet_names[1:]
]
sheets_service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": requests}
).execute()
return {
"spreadsheetId": spreadsheet_id,
"spreadsheetUrl": spreadsheet_url,
"title": title,
}
except Exception as e:
return {"error": str(e)}
class GoogleSheetsUpdateCellBlock(Block):
"""Update a single cell in a Google Sheets spreadsheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
cell: str = SchemaField(
description="Cell address in A1 notation (e.g., 'A1', 'Sheet1!B2')",
placeholder="A1",
)
value: str = SchemaField(
description="Value to write to the cell",
)
value_input_option: ValueInputOption = SchemaField(
description="How input data should be interpreted",
default=ValueInputOption.USER_ENTERED,
advanced=True,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="The result of the update operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
)
error: str = SchemaField(
description="Error message if any",
)
def __init__(self):
super().__init__(
id="df521b68-62d9-42e4-924f-fb6c245516fc",
description="Update a single cell in a Google Sheets spreadsheet.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsUpdateCellBlock.Input,
output_schema=GoogleSheetsUpdateCellBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"cell": "A1",
"value": "Hello World",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"result",
{"updatedCells": 1, "updatedColumns": 1, "updatedRows": 1},
),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_update_cell": lambda *args, **kwargs: {
"updatedCells": 1,
"updatedColumns": 1,
"updatedRows": 1,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
try:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
# Check if the selected file is actually a Google Sheets spreadsheet
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._update_cell,
service,
input_data.spreadsheet.id,
input_data.cell,
input_data.value,
input_data.value_input_option,
)
yield "result", result
# Output the GoogleDriveFile for chaining (preserves credentials_id)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", _handle_sheets_api_error(str(e), "update")
def _update_cell(
self,
service,
spreadsheet_id: str,
cell: str,
value: str,
value_input_option: ValueInputOption,
) -> dict:
body = {"values": [[value]]}
result = (
service.spreadsheets()
.values()
.update(
spreadsheetId=spreadsheet_id,
range=cell,
valueInputOption=value_input_option.value,
body=body,
)
.execute()
)
return {
"updatedCells": result.get("updatedCells", 0),
"updatedRows": result.get("updatedRows", 0),
"updatedColumns": result.get("updatedColumns", 0),
}
class FilterOperator(str, Enum):
EQUALS = "equals"
NOT_EQUALS = "not_equals"
CONTAINS = "contains"
NOT_CONTAINS = "not_contains"
GREATER_THAN = "greater_than"
LESS_THAN = "less_than"
GREATER_THAN_OR_EQUAL = "greater_than_or_equal"
LESS_THAN_OR_EQUAL = "less_than_or_equal"
IS_EMPTY = "is_empty"
IS_NOT_EMPTY = "is_not_empty"
class SortOrder(str, Enum):
ASCENDING = "ascending"
DESCENDING = "descending"
def _column_letter_to_index(letter: str) -> int:
"""Convert column letter (A, B, ..., Z, AA, AB, ...) to 0-based index."""
result = 0
for char in letter.upper():
result = result * 26 + (ord(char) - ord("A") + 1)
return result - 1
def _index_to_column_letter(index: int) -> str:
"""Convert 0-based column index to column letter (A, B, ..., Z, AA, AB, ...)."""
result = ""
index += 1 # Convert to 1-based
while index > 0:
index, remainder = divmod(index - 1, 26)
result = chr(ord("A") + remainder) + result
return result
def _apply_filter(
cell_value: str,
filter_value: str,
operator: FilterOperator,
match_case: bool,
) -> bool:
"""Apply a filter condition to a cell value."""
if operator == FilterOperator.IS_EMPTY:
return cell_value.strip() == ""
if operator == FilterOperator.IS_NOT_EMPTY:
return cell_value.strip() != ""
# For comparison operators, apply case sensitivity
compare_cell = cell_value if match_case else cell_value.lower()
compare_filter = filter_value if match_case else filter_value.lower()
if operator == FilterOperator.EQUALS:
return compare_cell == compare_filter
elif operator == FilterOperator.NOT_EQUALS:
return compare_cell != compare_filter
elif operator == FilterOperator.CONTAINS:
return compare_filter in compare_cell
elif operator == FilterOperator.NOT_CONTAINS:
return compare_filter not in compare_cell
elif operator in (
FilterOperator.GREATER_THAN,
FilterOperator.LESS_THAN,
FilterOperator.GREATER_THAN_OR_EQUAL,
FilterOperator.LESS_THAN_OR_EQUAL,
):
# Try numeric comparison first
try:
num_cell = float(cell_value)
num_filter = float(filter_value)
if operator == FilterOperator.GREATER_THAN:
return num_cell > num_filter
elif operator == FilterOperator.LESS_THAN:
return num_cell < num_filter
elif operator == FilterOperator.GREATER_THAN_OR_EQUAL:
return num_cell >= num_filter
elif operator == FilterOperator.LESS_THAN_OR_EQUAL:
return num_cell <= num_filter
except ValueError:
# Fall back to string comparison
if operator == FilterOperator.GREATER_THAN:
return compare_cell > compare_filter
elif operator == FilterOperator.LESS_THAN:
return compare_cell < compare_filter
elif operator == FilterOperator.GREATER_THAN_OR_EQUAL:
return compare_cell >= compare_filter
elif operator == FilterOperator.LESS_THAN_OR_EQUAL:
return compare_cell <= compare_filter
return False
class GoogleSheetsFilterRowsBlock(Block):
"""Filter rows in a Google Sheet based on column conditions."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
filter_column: str = SchemaField(
description="Column to filter on (header name or column letter like 'A', 'B')",
placeholder="Status",
)
filter_value: str = SchemaField(
description="Value to filter by (not used for is_empty/is_not_empty operators)",
default="",
)
operator: FilterOperator = SchemaField(
description="Filter comparison operator",
default=FilterOperator.EQUALS,
)
match_case: bool = SchemaField(
description="Whether to match case in comparisons",
default=False,
)
include_header: bool = SchemaField(
description="Include header row in output",
default=True,
)
class Output(BlockSchemaOutput):
rows: list[list[str]] = SchemaField(
description="Filtered rows (including header if requested)",
)
row_indices: list[int] = SchemaField(
description="Original 1-based row indices of matching rows (useful for deletion)",
)
count: int = SchemaField(
description="Number of matching rows (excluding header)",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="582195c2-ccee-4fc2-b646-18f72eb9906c",
description="Filter rows in a Google Sheet based on a column condition. Returns matching rows and their indices.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsFilterRowsBlock.Input,
output_schema=GoogleSheetsFilterRowsBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"filter_column": "Status",
"filter_value": "Active",
"operator": FilterOperator.EQUALS,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"rows",
[
["Name", "Status", "Score"],
["Alice", "Active", "85"],
["Charlie", "Active", "92"],
],
),
("row_indices", [2, 4]),
("count", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_filter_rows": lambda *args, **kwargs: {
"rows": [
["Name", "Status", "Score"],
["Alice", "Active", "85"],
["Charlie", "Active", "92"],
],
"row_indices": [2, 4],
"count": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._filter_rows,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.filter_column,
input_data.filter_value,
input_data.operator,
input_data.match_case,
input_data.include_header,
)
yield "rows", result["rows"]
yield "row_indices", result["row_indices"]
yield "count", result["count"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to filter rows: {str(e)}"
def _filter_rows(
self,
service,
spreadsheet_id: str,
sheet_name: str,
filter_column: str,
filter_value: str,
operator: FilterOperator,
match_case: bool,
include_header: bool,
) -> dict:
# Resolve sheet name
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
# Read all data from the sheet
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
if not all_rows:
return {"rows": [], "row_indices": [], "count": 0}
header = all_rows[0]
data_rows = all_rows[1:]
# Determine filter column index
filter_col_idx = -1
# First, try to match against header names (handles "ID", "No", "To", etc.)
for idx, col_name in enumerate(header):
if (match_case and col_name == filter_column) or (
not match_case and col_name.lower() == filter_column.lower()
):
filter_col_idx = idx
break
# If no header match and looks like a column letter (A, B, AA, etc.), try that
if filter_col_idx < 0 and filter_column.isalpha() and len(filter_column) <= 2:
filter_col_idx = _column_letter_to_index(filter_column)
# Validate column letter is within data range
if filter_col_idx >= len(header):
raise ValueError(
f"Column '{filter_column}' (index {filter_col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if filter_col_idx < 0:
raise ValueError(
f"Column '{filter_column}' not found. Available columns: {header}"
)
# Filter rows
filtered_rows = []
row_indices = []
for row_idx, row in enumerate(data_rows):
# Get cell value (handle rows shorter than filter column)
cell_value = row[filter_col_idx] if filter_col_idx < len(row) else ""
if _apply_filter(str(cell_value), filter_value, operator, match_case):
filtered_rows.append(row)
row_indices.append(row_idx + 2) # +2 for 1-based index and header
# Prepare output
output_rows = []
if include_header:
output_rows.append(header)
output_rows.extend(filtered_rows)
return {
"rows": output_rows,
"row_indices": row_indices,
"count": len(filtered_rows),
}
class GoogleSheetsLookupRowBlock(Block):
"""Look up a row by matching a value in a column (VLOOKUP-style)."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
lookup_column: str = SchemaField(
description="Column to search in (header name or column letter)",
placeholder="ID",
)
lookup_value: str = SchemaField(
description="Value to search for",
)
return_columns: list[str] = SchemaField(
description="Columns to return (header names or letters). Empty = all columns.",
default=[],
)
match_case: bool = SchemaField(
description="Whether to match case",
default=False,
)
class Output(BlockSchemaOutput):
row: list[str] = SchemaField(
description="The matching row (all or selected columns)",
)
row_dict: dict[str, str] = SchemaField(
description="The matching row as a dictionary (header: value)",
)
row_index: int = SchemaField(
description="1-based row index of the match",
)
found: bool = SchemaField(
description="Whether a match was found",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="e58c0bad-6597-400c-9548-d151ec428ffc",
description="Look up a row by finding a value in a specific column. Returns the first matching row and optionally specific columns.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsLookupRowBlock.Input,
output_schema=GoogleSheetsLookupRowBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"lookup_column": "ID",
"lookup_value": "123",
"return_columns": ["Name", "Email"],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("row", ["Alice", "alice@example.com"]),
("row_dict", {"Name": "Alice", "Email": "alice@example.com"}),
("row_index", 2),
("found", True),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_lookup_row": lambda *args, **kwargs: {
"row": ["Alice", "alice@example.com"],
"row_dict": {"Name": "Alice", "Email": "alice@example.com"},
"row_index": 2,
"found": True,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._lookup_row,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.lookup_column,
input_data.lookup_value,
input_data.return_columns,
input_data.match_case,
)
yield "row", result["row"]
yield "row_dict", result["row_dict"]
yield "row_index", result["row_index"]
yield "found", result["found"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to lookup row: {str(e)}"
def _lookup_row(
self,
service,
spreadsheet_id: str,
sheet_name: str,
lookup_column: str,
lookup_value: str,
return_columns: list[str],
match_case: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
if not all_rows:
return {"row": [], "row_dict": {}, "row_index": 0, "found": False}
header = all_rows[0]
data_rows = all_rows[1:]
# Find lookup column index - first try header name match, then column letter
lookup_col_idx = -1
for idx, col_name in enumerate(header):
if (match_case and col_name == lookup_column) or (
not match_case and col_name.lower() == lookup_column.lower()
):
lookup_col_idx = idx
break
# If no header match and looks like a column letter, try that
if lookup_col_idx < 0 and lookup_column.isalpha() and len(lookup_column) <= 2:
lookup_col_idx = _column_letter_to_index(lookup_column)
# Validate column letter is within data range
if lookup_col_idx >= len(header):
raise ValueError(
f"Column '{lookup_column}' (index {lookup_col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if lookup_col_idx < 0:
raise ValueError(
f"Lookup column '{lookup_column}' not found. Available: {header}"
)
# Find return column indices - first try header name match, then column letter
return_col_indices = []
return_col_headers = []
if return_columns:
for ret_col in return_columns:
found = False
# First try header name match
for idx, col_name in enumerate(header):
if (match_case and col_name == ret_col) or (
not match_case and col_name.lower() == ret_col.lower()
):
return_col_indices.append(idx)
return_col_headers.append(col_name)
found = True
break
# If no header match and looks like a column letter, try that
if not found and ret_col.isalpha() and len(ret_col) <= 2:
idx = _column_letter_to_index(ret_col)
# Validate column letter is within data range
if idx >= len(header):
raise ValueError(
f"Return column '{ret_col}' (index {idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
return_col_indices.append(idx)
return_col_headers.append(header[idx])
found = True
if not found:
raise ValueError(
f"Return column '{ret_col}' not found. Available: {header}"
)
else:
return_col_indices = list(range(len(header)))
return_col_headers = header
# Search for matching row
compare_value = lookup_value if match_case else lookup_value.lower()
for row_idx, row in enumerate(data_rows):
cell_value = row[lookup_col_idx] if lookup_col_idx < len(row) else ""
compare_cell = str(cell_value) if match_case else str(cell_value).lower()
if compare_cell == compare_value:
# Found a match - extract requested columns
result_row = []
result_dict = {}
for i, col_idx in enumerate(return_col_indices):
value = row[col_idx] if col_idx < len(row) else ""
result_row.append(value)
result_dict[return_col_headers[i]] = value
return {
"row": result_row,
"row_dict": result_dict,
"row_index": row_idx + 2,
"found": True,
}
return {"row": [], "row_dict": {}, "row_index": 0, "found": False}
class GoogleSheetsDeleteRowsBlock(Block):
"""Delete rows from a Google Sheet by row indices."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
row_indices: list[int] = SchemaField(
description="1-based row indices to delete (e.g., [2, 5, 7])",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the delete operation",
)
deleted_count: int = SchemaField(
description="Number of rows deleted",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="24bcd490-b02d-44c6-847d-b62a2319f5eb",
description="Delete specific rows from a Google Sheet by their row indices. Works well with FilterRowsBlock output.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsDeleteRowsBlock.Input,
output_schema=GoogleSheetsDeleteRowsBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"row_indices": [2, 5],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("deleted_count", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_delete_rows": lambda *args, **kwargs: {
"success": True,
"deleted_count": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._delete_rows,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.row_indices,
)
yield "result", {"success": True}
yield "deleted_count", result["deleted_count"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to delete rows: {str(e)}"
def _delete_rows(
self,
service,
spreadsheet_id: str,
sheet_name: str,
row_indices: list[int],
) -> dict:
if not row_indices:
return {"success": True, "deleted_count": 0}
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Deduplicate and sort row indices in descending order to delete from bottom to top
# Deduplication prevents deleting wrong rows if same index appears multiple times
sorted_indices = sorted(set(row_indices), reverse=True)
# Build delete requests
requests = []
for row_idx in sorted_indices:
# Convert to 0-based index
start_idx = row_idx - 1
requests.append(
{
"deleteDimension": {
"range": {
"sheetId": sheet_id,
"dimension": "ROWS",
"startIndex": start_idx,
"endIndex": start_idx + 1,
}
}
}
)
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": requests}
).execute()
return {"success": True, "deleted_count": len(sorted_indices)}
class GoogleSheetsGetColumnBlock(Block):
"""Get all values from a specific column by header name."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
column: str = SchemaField(
description="Column to extract (header name or column letter like 'A', 'B')",
placeholder="Email",
)
include_header: bool = SchemaField(
description="Include the header in output",
default=False,
)
skip_empty: bool = SchemaField(
description="Skip empty cells",
default=False,
)
class Output(BlockSchemaOutput):
values: list[str] = SchemaField(
description="List of values from the column",
)
count: int = SchemaField(
description="Number of values (excluding header if not included)",
)
column_index: int = SchemaField(
description="0-based column index",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="108d911f-e109-47fb-addc-2259792ee850",
description="Extract all values from a specific column. Useful for getting a list of emails, IDs, or any single field.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsGetColumnBlock.Input,
output_schema=GoogleSheetsGetColumnBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"column": "Email",
"include_header": False,
"skip_empty": True,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"values",
["alice@example.com", "bob@example.com", "charlie@example.com"],
),
("count", 3),
("column_index", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_get_column": lambda *args, **kwargs: {
"values": [
"alice@example.com",
"bob@example.com",
"charlie@example.com",
],
"count": 3,
"column_index": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._get_column,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.column,
input_data.include_header,
input_data.skip_empty,
)
yield "values", result["values"]
yield "count", result["count"]
yield "column_index", result["column_index"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to get column: {str(e)}"
def _get_column(
self,
service,
spreadsheet_id: str,
sheet_name: str,
column: str,
include_header: bool,
skip_empty: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
if not all_rows:
return {"values": [], "count": 0, "column_index": -1}
header = all_rows[0]
# Find column index - first try header name match, then column letter
col_idx = -1
for idx, col_name in enumerate(header):
if col_name.lower() == column.lower():
col_idx = idx
break
# If no header match and looks like a column letter, try that
if col_idx < 0 and column.isalpha() and len(column) <= 2:
col_idx = _column_letter_to_index(column)
# Validate column letter is within data range
if col_idx >= len(header):
raise ValueError(
f"Column '{column}' (index {col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if col_idx < 0:
raise ValueError(
f"Column '{column}' not found. Available columns: {header}"
)
# Extract column values
values = []
start_row = 0 if include_header else 1
for row in all_rows[start_row:]:
value = row[col_idx] if col_idx < len(row) else ""
if skip_empty and not str(value).strip():
continue
values.append(str(value))
return {"values": values, "count": len(values), "column_index": col_idx}
class GoogleSheetsSortBlock(Block):
"""Sort a Google Sheet by one or more columns."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
sort_column: str = SchemaField(
description="Primary column to sort by (header name or column letter)",
placeholder="Date",
)
sort_order: SortOrder = SchemaField(
description="Sort order for primary column",
default=SortOrder.ASCENDING,
)
secondary_column: str = SchemaField(
description="Secondary column to sort by (optional)",
default="",
)
secondary_order: SortOrder = SchemaField(
description="Sort order for secondary column",
default=SortOrder.ASCENDING,
)
has_header: bool = SchemaField(
description="Whether the data has a header row (header won't be sorted)",
default=True,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the sort operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="a265bd84-c93b-459d-bbe0-94e6addaa38f",
description="Sort a Google Sheet by one or two columns. The sheet is sorted in-place.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsSortBlock.Input,
output_schema=GoogleSheetsSortBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"sort_column": "Score",
"sort_order": SortOrder.DESCENDING,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_sort_sheet": lambda *args, **kwargs: {"success": True},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._sort_sheet,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.sort_column,
input_data.sort_order,
input_data.secondary_column,
input_data.secondary_order,
input_data.has_header,
)
yield "result", result
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to sort sheet: {str(e)}"
def _sort_sheet(
self,
service,
spreadsheet_id: str,
sheet_name: str,
sort_column: str,
sort_order: SortOrder,
secondary_column: str,
secondary_order: SortOrder,
has_header: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Get sheet metadata to find column indices and grid properties
meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
sheet_meta = None
for sheet in meta.get("sheets", []):
if sheet.get("properties", {}).get("sheetId") == sheet_id:
sheet_meta = sheet
break
if not sheet_meta:
raise ValueError(f"Could not find metadata for sheet '{target_sheet}'")
grid_props = sheet_meta.get("properties", {}).get("gridProperties", {})
row_count = grid_props.get("rowCount", 1000)
col_count = grid_props.get("columnCount", 26)
# Get header to resolve column names
formatted_sheet = format_sheet_name(target_sheet)
header_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=f"{formatted_sheet}!1:1")
.execute()
)
header = (
header_result.get("values", [[]])[0] if header_result.get("values") else []
)
# Find primary sort column index - first try header name match, then column letter
sort_col_idx = -1
for idx, col_name in enumerate(header):
if col_name.lower() == sort_column.lower():
sort_col_idx = idx
break
# If no header match and looks like a column letter, try that
if sort_col_idx < 0 and sort_column.isalpha() and len(sort_column) <= 2:
sort_col_idx = _column_letter_to_index(sort_column)
# Validate column letter is within data range
if sort_col_idx >= len(header):
raise ValueError(
f"Sort column '{sort_column}' (index {sort_col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if sort_col_idx < 0:
raise ValueError(
f"Sort column '{sort_column}' not found. Available: {header}"
)
# Build sort specs
sort_specs = [
{
"dimensionIndex": sort_col_idx,
"sortOrder": (
"ASCENDING" if sort_order == SortOrder.ASCENDING else "DESCENDING"
),
}
]
# Add secondary sort if specified
if secondary_column:
sec_col_idx = -1
# First try header name match
for idx, col_name in enumerate(header):
if col_name.lower() == secondary_column.lower():
sec_col_idx = idx
break
# If no header match and looks like a column letter, try that
if (
sec_col_idx < 0
and secondary_column.isalpha()
and len(secondary_column) <= 2
):
sec_col_idx = _column_letter_to_index(secondary_column)
# Validate column letter is within data range
if sec_col_idx >= len(header):
raise ValueError(
f"Secondary sort column '{secondary_column}' (index {sec_col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if sec_col_idx < 0:
raise ValueError(
f"Secondary sort column '{secondary_column}' not found. Available: {header}"
)
sort_specs.append(
{
"dimensionIndex": sec_col_idx,
"sortOrder": (
"ASCENDING"
if secondary_order == SortOrder.ASCENDING
else "DESCENDING"
),
}
)
# Build sort range request
start_row = 1 if has_header else 0 # Skip header if present
request = {
"sortRange": {
"range": {
"sheetId": sheet_id,
"startRowIndex": start_row,
"endRowIndex": row_count,
"startColumnIndex": 0,
"endColumnIndex": col_count,
},
"sortSpecs": sort_specs,
}
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": [request]}
).execute()
return {"success": True}
class GoogleSheetsGetUniqueValuesBlock(Block):
"""Get unique values from a column."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
column: str = SchemaField(
description="Column to get unique values from (header name or column letter)",
placeholder="Category",
)
include_count: bool = SchemaField(
description="Include count of each unique value",
default=False,
)
sort_by_count: bool = SchemaField(
description="Sort results by count (most frequent first)",
default=False,
)
class Output(BlockSchemaOutput):
values: list[str] = SchemaField(
description="List of unique values",
)
counts: dict[str, int] = SchemaField(
description="Count of each unique value (if include_count is True)",
)
total_unique: int = SchemaField(
description="Total number of unique values",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="0f296c0b-6b6e-4280-b96e-ae1459b98dff",
description="Get unique values from a column. Useful for building dropdown options or finding distinct categories.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsGetUniqueValuesBlock.Input,
output_schema=GoogleSheetsGetUniqueValuesBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"column": "Status",
"include_count": True,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("values", ["Active", "Inactive", "Pending"]),
("counts", {"Active": 5, "Inactive": 3, "Pending": 2}),
("total_unique", 3),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_get_unique_values": lambda *args, **kwargs: {
"values": ["Active", "Inactive", "Pending"],
"counts": {"Active": 5, "Inactive": 3, "Pending": 2},
"total_unique": 3,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._get_unique_values,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.column,
input_data.include_count,
input_data.sort_by_count,
)
yield "values", result["values"]
yield "counts", result["counts"]
yield "total_unique", result["total_unique"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to get unique values: {str(e)}"
def _get_unique_values(
self,
service,
spreadsheet_id: str,
sheet_name: str,
column: str,
include_count: bool,
sort_by_count: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
if not all_rows:
return {"values": [], "counts": {}, "total_unique": 0}
header = all_rows[0]
# Find column index - first try header name match, then column letter
col_idx = -1
for idx, col_name in enumerate(header):
if col_name.lower() == column.lower():
col_idx = idx
break
# If no header match and looks like a column letter, try that
if col_idx < 0 and column.isalpha() and len(column) <= 2:
col_idx = _column_letter_to_index(column)
# Validate column letter is within data range
if col_idx >= len(header):
raise ValueError(
f"Column '{column}' (index {col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if col_idx < 0:
raise ValueError(
f"Column '{column}' not found. Available columns: {header}"
)
# Count values
value_counts: dict[str, int] = {}
for row in all_rows[1:]: # Skip header
value = str(row[col_idx]) if col_idx < len(row) else ""
if value.strip(): # Skip empty values
value_counts[value] = value_counts.get(value, 0) + 1
# Sort values
if sort_by_count:
sorted_items = sorted(value_counts.items(), key=lambda x: -x[1])
unique_values = [item[0] for item in sorted_items]
else:
unique_values = sorted(value_counts.keys())
return {
"values": unique_values,
"counts": value_counts if include_count else {},
"total_unique": len(unique_values),
}
class GoogleSheetsInsertRowBlock(Block):
"""Insert a single row at a specific position in a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
row: list[str] = SchemaField(
description="Row values to insert (e.g., ['Alice', 'alice@example.com', '25'])",
)
row_index: int = SchemaField(
description="1-based row index where to insert (existing rows shift down)",
placeholder="2",
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
value_input_option: ValueInputOption = SchemaField(
description="How values are interpreted. USER_ENTERED: parsed like typed input (e.g., '=SUM(A1:A5)' becomes a formula, '1/2/2024' becomes a date). RAW: stored as-is without parsing.",
default=ValueInputOption.USER_ENTERED,
advanced=True,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Result of the insert operation")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="03eda5df-8080-4ed1-bfdf-212f543d657e",
description="Insert a single row at a specific position. Existing rows shift down.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsInsertRowBlock.Input,
output_schema=GoogleSheetsInsertRowBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"row": ["New", "Row", "Data"],
"row_index": 3,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_insert_row": lambda *args, **kwargs: {"success": True},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
if not input_data.row:
yield "error", "Row data is required"
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._insert_row,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.row_index,
input_data.row,
input_data.value_input_option,
)
yield "result", result
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to insert row: {str(e)}"
def _insert_row(
self,
service,
spreadsheet_id: str,
sheet_name: str,
row_index: int,
row: list[str],
value_input_option: ValueInputOption,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
start_idx = row_index - 1 # Convert to 0-based
# First, insert an empty row
insert_request = {
"insertDimension": {
"range": {
"sheetId": sheet_id,
"dimension": "ROWS",
"startIndex": start_idx,
"endIndex": start_idx + 1,
},
"inheritFromBefore": start_idx > 0,
}
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": [insert_request]}
).execute()
# Then, write the values
formatted_sheet = format_sheet_name(target_sheet)
write_range = f"{formatted_sheet}!A{row_index}"
service.spreadsheets().values().update(
spreadsheetId=spreadsheet_id,
range=write_range,
valueInputOption=value_input_option.value,
body={"values": [row]}, # Wrap single row in list for API
).execute()
return {"success": True}
class GoogleSheetsAddColumnBlock(Block):
"""Add a new column with a header to a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
header: str = SchemaField(
description="Header name for the new column",
placeholder="New Column",
)
position: str = SchemaField(
description="Where to add: 'end' for last column, or column letter (e.g., 'C') to insert before",
default="end",
)
default_value: str = SchemaField(
description="Default value to fill in all data rows (optional). Requires existing data rows.",
default="",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the operation",
)
column_letter: str = SchemaField(
description="Letter of the new column (e.g., 'D')",
)
column_index: int = SchemaField(
description="0-based index of the new column",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="cac51050-fc9e-4e63-987a-66c2ba2a127b",
description="Add a new column with a header. Can add at the end or insert at a specific position.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsAddColumnBlock.Input,
output_schema=GoogleSheetsAddColumnBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"header": "New Status",
"position": "end",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("column_letter", "D"),
("column_index", 3),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_add_column": lambda *args, **kwargs: {
"success": True,
"column_letter": "D",
"column_index": 3,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._add_column,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.header,
input_data.position,
input_data.default_value,
)
yield "result", {"success": True}
yield "column_letter", result["column_letter"]
yield "column_index", result["column_index"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to add column: {str(e)}"
def _add_column(
self,
service,
spreadsheet_id: str,
sheet_name: str,
header: str,
position: str,
default_value: str,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Get current data to determine column count and row count
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
current_col_count = max(len(row) for row in all_rows) if all_rows else 0
row_count = len(all_rows)
# Determine target column index
if position.lower() == "end":
col_idx = current_col_count
elif position.isalpha() and len(position) <= 2:
col_idx = _column_letter_to_index(position)
# Insert a new column at this position
insert_request = {
"insertDimension": {
"range": {
"sheetId": sheet_id,
"dimension": "COLUMNS",
"startIndex": col_idx,
"endIndex": col_idx + 1,
},
"inheritFromBefore": col_idx > 0,
}
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": [insert_request]}
).execute()
else:
raise ValueError(
f"Invalid position: '{position}'. Use 'end' or a column letter."
)
col_letter = _index_to_column_letter(col_idx)
# Write header
header_range = f"{formatted_sheet}!{col_letter}1"
service.spreadsheets().values().update(
spreadsheetId=spreadsheet_id,
range=header_range,
valueInputOption="USER_ENTERED",
body={"values": [[header]]},
).execute()
# Fill default value if provided and there are data rows
if default_value and row_count > 1:
values_to_fill = [[default_value]] * (row_count - 1)
data_range = f"{formatted_sheet}!{col_letter}2:{col_letter}{row_count}"
service.spreadsheets().values().update(
spreadsheetId=spreadsheet_id,
range=data_range,
valueInputOption="USER_ENTERED",
body={"values": values_to_fill},
).execute()
return {
"success": True,
"column_letter": col_letter,
"column_index": col_idx,
}
class GoogleSheetsGetRowCountBlock(Block):
"""Get the number of rows in a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
include_header: bool = SchemaField(
description="Include header row in count",
default=True,
)
count_empty: bool = SchemaField(
description="Count rows with only empty cells",
default=False,
)
class Output(BlockSchemaOutput):
total_rows: int = SchemaField(
description="Total number of rows",
)
data_rows: int = SchemaField(
description="Number of data rows (excluding header)",
)
last_row: int = SchemaField(
description="1-based index of the last row with data",
)
column_count: int = SchemaField(
description="Number of columns",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="080cc84b-a94a-4fb4-90e3-dcc55ee783af",
description="Get row count and dimensions of a Google Sheet. Useful for knowing where data ends.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsGetRowCountBlock.Input,
output_schema=GoogleSheetsGetRowCountBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("total_rows", 101),
("data_rows", 100),
("last_row", 101),
("column_count", 5),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_get_row_count": lambda *args, **kwargs: {
"total_rows": 101,
"data_rows": 100,
"last_row": 101,
"column_count": 5,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._get_row_count,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.include_header,
input_data.count_empty,
)
yield "total_rows", result["total_rows"]
yield "data_rows", result["data_rows"]
yield "last_row", result["last_row"]
yield "column_count", result["column_count"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to get row count: {str(e)}"
def _get_row_count(
self,
service,
spreadsheet_id: str,
sheet_name: str,
include_header: bool,
count_empty: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
if not all_rows:
return {
"total_rows": 0,
"data_rows": 0,
"last_row": 0,
"column_count": 0,
}
# Count non-empty rows
if count_empty:
total_rows = len(all_rows)
last_row = total_rows
else:
# Find last row with actual data
last_row = 0
for idx, row in enumerate(all_rows):
if any(str(cell).strip() for cell in row):
last_row = idx + 1
total_rows = last_row
data_rows = total_rows - 1 if total_rows > 0 else 0
if not include_header:
total_rows = data_rows
column_count = max(len(row) for row in all_rows) if all_rows else 0
return {
"total_rows": total_rows,
"data_rows": data_rows,
"last_row": last_row,
"column_count": column_count,
}
class GoogleSheetsRemoveDuplicatesBlock(Block):
"""Remove duplicate rows from a Google Sheet based on specified columns."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
columns: list[str] = SchemaField(
description="Columns to check for duplicates (header names or letters). Empty = all columns.",
default=[],
)
keep: str = SchemaField(
description="Which duplicate to keep: 'first' or 'last'",
default="first",
)
match_case: bool = SchemaField(
description="Whether to match case when comparing",
default=False,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the operation",
)
removed_count: int = SchemaField(
description="Number of duplicate rows removed",
)
remaining_rows: int = SchemaField(
description="Number of rows remaining",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="6eb50ff7-205b-400e-8ecc-1ce8d50075be",
description="Remove duplicate rows based on specified columns. Keeps either the first or last occurrence.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsRemoveDuplicatesBlock.Input,
output_schema=GoogleSheetsRemoveDuplicatesBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"columns": ["Email"],
"keep": "first",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("removed_count", 5),
("remaining_rows", 95),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_remove_duplicates": lambda *args, **kwargs: {
"success": True,
"removed_count": 5,
"remaining_rows": 95,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._remove_duplicates,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.columns,
input_data.keep,
input_data.match_case,
)
yield "result", {"success": True}
yield "removed_count", result["removed_count"]
yield "remaining_rows", result["remaining_rows"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to remove duplicates: {str(e)}"
def _remove_duplicates(
self,
service,
spreadsheet_id: str,
sheet_name: str,
columns: list[str],
keep: str,
match_case: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Read all data
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=formatted_sheet)
.execute()
)
all_rows = result.get("values", [])
if len(all_rows) <= 1: # Only header or empty
return {
"success": True,
"removed_count": 0,
"remaining_rows": len(all_rows),
}
header = all_rows[0]
data_rows = all_rows[1:]
# Determine which column indices to use for comparison
# First try header name match, then column letter
if columns:
col_indices = []
for col in columns:
found = False
# First try header name match
for idx, col_name in enumerate(header):
if col_name.lower() == col.lower():
col_indices.append(idx)
found = True
break
# If no header match and looks like a column letter, try that
if not found and col.isalpha() and len(col) <= 2:
col_idx = _column_letter_to_index(col)
# Validate column letter is within data range
if col_idx >= len(header):
raise ValueError(
f"Column '{col}' (index {col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
col_indices.append(col_idx)
found = True
if not found:
raise ValueError(
f"Column '{col}' not found in sheet. "
f"Available columns: {', '.join(header)}"
)
else:
col_indices = list(range(len(header)))
# Find duplicates
seen: dict[tuple, int] = {}
rows_to_delete: list[int] = []
for row_idx, row in enumerate(data_rows):
# Build key from specified columns
key_parts = []
for col_idx in col_indices:
value = str(row[col_idx]) if col_idx < len(row) else ""
if not match_case:
value = value.lower()
key_parts.append(value)
key = tuple(key_parts)
if key in seen:
if keep == "first":
# Delete this row (keep the first one we saw)
rows_to_delete.append(row_idx + 2) # +2 for 1-based and header
else:
# Delete the previous row, then update seen to keep this one
prev_row = seen[key]
rows_to_delete.append(prev_row)
seen[key] = row_idx + 2
else:
seen[key] = row_idx + 2
if not rows_to_delete:
return {
"success": True,
"removed_count": 0,
"remaining_rows": len(all_rows),
}
# Sort in descending order to delete from bottom to top
rows_to_delete = sorted(set(rows_to_delete), reverse=True)
# Delete rows
requests = []
for row_idx in rows_to_delete:
start_idx = row_idx - 1
requests.append(
{
"deleteDimension": {
"range": {
"sheetId": sheet_id,
"dimension": "ROWS",
"startIndex": start_idx,
"endIndex": start_idx + 1,
}
}
}
)
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": requests}
).execute()
remaining = len(all_rows) - len(rows_to_delete)
return {
"success": True,
"removed_count": len(rows_to_delete),
"remaining_rows": remaining,
}
class GoogleSheetsUpdateRowBlock(Block):
"""Update a specific row by index with new values."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
row_index: int = SchemaField(
description="1-based row index to update",
)
values: list[str] = SchemaField(
description="New values for the row (in column order)",
default=[],
)
dict_values: dict[str, str] = SchemaField(
description="Values as dict with column headers as keys (alternative to values)",
default={},
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the update operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="b8a934d5-fca0-4be3-9fc2-a99bf63bd385",
description="Update a specific row by its index. Can use list or dict format for values.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsUpdateRowBlock.Input,
output_schema=GoogleSheetsUpdateRowBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"row_index": 5,
"dict_values": {"Name": "Updated Name", "Status": "Active"},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True, "updatedCells": 2}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_update_row": lambda *args, **kwargs: {
"success": True,
"updatedCells": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
if not input_data.values and not input_data.dict_values:
yield "error", "Either values or dict_values must be provided"
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._update_row,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.row_index,
input_data.values,
input_data.dict_values,
)
yield "result", result
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to update row: {str(e)}"
def _update_row(
self,
service,
spreadsheet_id: str,
sheet_name: str,
row_index: int,
values: list[str],
dict_values: dict[str, str],
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
if dict_values:
# Get header to map column names to indices
header_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=f"{formatted_sheet}!1:1")
.execute()
)
header = (
header_result.get("values", [[]])[0]
if header_result.get("values")
else []
)
# Get current row values
row_range = f"{formatted_sheet}!{row_index}:{row_index}"
current_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=row_range)
.execute()
)
current_row = (
current_result.get("values", [[]])[0]
if current_result.get("values")
else []
)
# Extend current row to match header length
while len(current_row) < len(header):
current_row.append("")
# Update specific columns from dict - validate all column names first
for col_name in dict_values.keys():
found = False
for h in header:
if h.lower() == col_name.lower():
found = True
break
if not found:
raise ValueError(
f"Column '{col_name}' not found in sheet. "
f"Available columns: {', '.join(header)}"
)
# Now apply updates
updated_count = 0
for col_name, value in dict_values.items():
for idx, h in enumerate(header):
if h.lower() == col_name.lower():
current_row[idx] = value
updated_count += 1
break
values = current_row
else:
updated_count = len(values)
# Write the row
write_range = f"{formatted_sheet}!A{row_index}"
service.spreadsheets().values().update(
spreadsheetId=spreadsheet_id,
range=write_range,
valueInputOption="USER_ENTERED",
body={"values": [values]},
).execute()
return {"success": True, "updatedCells": updated_count}
class GoogleSheetsGetRowBlock(Block):
"""Get a specific row by its index."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
row_index: int = SchemaField(
description="1-based row index to retrieve",
)
class Output(BlockSchemaOutput):
row: list[str] = SchemaField(
description="The row values as a list",
)
row_dict: dict[str, str] = SchemaField(
description="The row as a dictionary (header: value)",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="c4be9390-2431-4682-9769-7025b22a5fa7",
description="Get a specific row by its index. Returns both list and dict formats.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsGetRowBlock.Input,
output_schema=GoogleSheetsGetRowBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"row_index": 3,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("row", ["Alice", "Active", "85"]),
("row_dict", {"Name": "Alice", "Status": "Active", "Score": "85"}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_get_row": lambda *args, **kwargs: {
"row": ["Alice", "Active", "85"],
"row_dict": {"Name": "Alice", "Status": "Active", "Score": "85"},
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._get_row,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.row_index,
)
yield "row", result["row"]
yield "row_dict", result["row_dict"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to get row: {str(e)}"
def _get_row(
self,
service,
spreadsheet_id: str,
sheet_name: str,
row_index: int,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
# Get header
header_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=f"{formatted_sheet}!1:1")
.execute()
)
header = (
header_result.get("values", [[]])[0] if header_result.get("values") else []
)
# Get the row
row_range = f"{formatted_sheet}!{row_index}:{row_index}"
row_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=row_range)
.execute()
)
row = row_result.get("values", [[]])[0] if row_result.get("values") else []
# Build dictionary
row_dict = {}
for idx, h in enumerate(header):
row_dict[h] = row[idx] if idx < len(row) else ""
return {"row": row, "row_dict": row_dict}
class GoogleSheetsDeleteColumnBlock(Block):
"""Delete a column from a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
column: str = SchemaField(
description="Column to delete (header name or column letter like 'A', 'B')",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the delete operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="59b266b6-5cce-4661-a1d3-c417e64d68e9",
description="Delete a column by header name or column letter.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsDeleteColumnBlock.Input,
output_schema=GoogleSheetsDeleteColumnBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"column": "Status",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_delete_column": lambda *args, **kwargs: {"success": True},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._delete_column,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.column,
)
yield "result", result
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to delete column: {str(e)}"
def _delete_column(
self,
service,
spreadsheet_id: str,
sheet_name: str,
column: str,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
formatted_sheet = format_sheet_name(target_sheet)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Get header to find column by name or validate column letter
header_result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=f"{formatted_sheet}!1:1")
.execute()
)
header = (
header_result.get("values", [[]])[0] if header_result.get("values") else []
)
# Find column index - first try header name match, then column letter
col_idx = -1
for idx, h in enumerate(header):
if h.lower() == column.lower():
col_idx = idx
break
# If no header match and looks like a column letter, try that
if col_idx < 0 and column.isalpha() and len(column) <= 2:
col_idx = _column_letter_to_index(column)
# Validate column letter is within data range
if col_idx >= len(header):
raise ValueError(
f"Column '{column}' (index {col_idx}) is out of range. "
f"Sheet only has {len(header)} columns (A-{_index_to_column_letter(len(header) - 1)})."
)
if col_idx < 0:
raise ValueError(f"Column '{column}' not found")
# Delete the column
request = {
"deleteDimension": {
"range": {
"sheetId": sheet_id,
"dimension": "COLUMNS",
"startIndex": col_idx,
"endIndex": col_idx + 1,
}
}
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": [request]}
).execute()
return {"success": True}
class GoogleSheetsCreateNamedRangeBlock(Block):
"""Create a named range in a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
name: str = SchemaField(
description="Name for the range (e.g., 'SalesData', 'CustomerList')",
placeholder="MyNamedRange",
)
range: str = SchemaField(
description="Cell range in A1 notation (e.g., 'A1:D10', 'B2:B100')",
placeholder="A1:D10",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the operation",
)
named_range_id: str = SchemaField(
description="ID of the created named range",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="a2707376-8016-494b-98c4-d0e2752ab9cb",
description="Create a named range to reference cells by name instead of A1 notation.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsCreateNamedRangeBlock.Input,
output_schema=GoogleSheetsCreateNamedRangeBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"name": "SalesData",
"range": "A1:D10",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("named_range_id", "nr_12345"),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_create_named_range": lambda *args, **kwargs: {
"success": True,
"named_range_id": "nr_12345",
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._create_named_range,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.name,
input_data.range,
)
yield "result", {"success": True}
yield "named_range_id", result["named_range_id"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to create named range: {str(e)}"
def _create_named_range(
self,
service,
spreadsheet_id: str,
sheet_name: str,
name: str,
range_str: str,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Parse range to get grid coordinates
# Handle both "A1:D10" and "Sheet1!A1:D10" formats
if "!" in range_str:
range_str = range_str.split("!")[1]
# Parse start and end cells
match = re.match(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", range_str.upper())
if not match:
raise ValueError(f"Invalid range format: {range_str}")
start_col = _column_letter_to_index(match.group(1))
start_row = int(match.group(2)) - 1 # 0-based
end_col = _column_letter_to_index(match.group(3)) + 1 # exclusive
end_row = int(match.group(4)) # exclusive (already 1-based becomes 0-based + 1)
request = {
"addNamedRange": {
"namedRange": {
"name": name,
"range": {
"sheetId": sheet_id,
"startRowIndex": start_row,
"endRowIndex": end_row,
"startColumnIndex": start_col,
"endColumnIndex": end_col,
},
}
}
}
result = (
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body={"requests": [request]})
.execute()
)
# Extract the named range ID from the response
named_range_id = ""
replies = result.get("replies", [])
if replies and "addNamedRange" in replies[0]:
named_range_id = replies[0]["addNamedRange"]["namedRange"]["namedRangeId"]
return {"success": True, "named_range_id": named_range_id}
class GoogleSheetsListNamedRangesBlock(Block):
"""List all named ranges in a Google Sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
class Output(BlockSchemaOutput):
named_ranges: list[dict] = SchemaField(
description="List of named ranges with name, id, and range info",
)
count: int = SchemaField(
description="Number of named ranges",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="b81a9d27-3997-4860-9303-cc68086db13a",
description="List all named ranges in a spreadsheet.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsListNamedRangesBlock.Input,
output_schema=GoogleSheetsListNamedRangesBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"named_ranges",
[
{"name": "SalesData", "id": "nr_1", "range": "Sheet1!A1:D10"},
{
"name": "CustomerList",
"id": "nr_2",
"range": "Sheet1!E1:F50",
},
],
),
("count", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_list_named_ranges": lambda *args, **kwargs: {
"named_ranges": [
{"name": "SalesData", "id": "nr_1", "range": "Sheet1!A1:D10"},
{
"name": "CustomerList",
"id": "nr_2",
"range": "Sheet1!E1:F50",
},
],
"count": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._list_named_ranges,
service,
input_data.spreadsheet.id,
)
yield "named_ranges", result["named_ranges"]
yield "count", result["count"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to list named ranges: {str(e)}"
def _list_named_ranges(
self,
service,
spreadsheet_id: str,
) -> dict:
# Get spreadsheet metadata including named ranges
meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
named_ranges_list = []
named_ranges = meta.get("namedRanges", [])
# Get sheet names for reference
sheets = {
sheet["properties"]["sheetId"]: sheet["properties"]["title"]
for sheet in meta.get("sheets", [])
}
for nr in named_ranges:
range_info = nr.get("range", {})
sheet_id = range_info.get("sheetId", 0)
sheet_name = sheets.get(sheet_id, "Sheet1")
# Convert grid range back to A1 notation
start_col = _index_to_column_letter(range_info.get("startColumnIndex", 0))
end_col = _index_to_column_letter(range_info.get("endColumnIndex", 1) - 1)
start_row = range_info.get("startRowIndex", 0) + 1
end_row = range_info.get("endRowIndex", 1)
range_str = f"{sheet_name}!{start_col}{start_row}:{end_col}{end_row}"
named_ranges_list.append(
{
"name": nr.get("name", ""),
"id": nr.get("namedRangeId", ""),
"range": range_str,
}
)
return {"named_ranges": named_ranges_list, "count": len(named_ranges_list)}
class GoogleSheetsAddDropdownBlock(Block):
"""Add a dropdown (data validation) to cells."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
range: str = SchemaField(
description="Cell range to add dropdown to (e.g., 'B2:B100')",
placeholder="B2:B100",
)
options: list[str] = SchemaField(
description="List of dropdown options",
)
strict: bool = SchemaField(
description="Reject input not in the list",
default=True,
)
show_dropdown: bool = SchemaField(
description="Show dropdown arrow in cells",
default=True,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the operation",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="725431c9-71ba-4fce-b829-5a3e495a8a88",
description="Add a dropdown list (data validation) to cells. Useful for enforcing valid inputs.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsAddDropdownBlock.Input,
output_schema=GoogleSheetsAddDropdownBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"range": "B2:B100",
"options": ["Active", "Inactive", "Pending"],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_add_dropdown": lambda *args, **kwargs: {"success": True},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
if not input_data.options:
yield "error", "Options list cannot be empty"
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._add_dropdown,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.range,
input_data.options,
input_data.strict,
input_data.show_dropdown,
)
yield "result", result
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to add dropdown: {str(e)}"
def _add_dropdown(
self,
service,
spreadsheet_id: str,
sheet_name: str,
range_str: str,
options: list[str],
strict: bool,
show_dropdown: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Parse range
if "!" in range_str:
range_str = range_str.split("!")[1]
match = re.match(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", range_str.upper())
if not match:
raise ValueError(f"Invalid range format: {range_str}")
start_col = _column_letter_to_index(match.group(1))
start_row = int(match.group(2)) - 1
end_col = _column_letter_to_index(match.group(3)) + 1
end_row = int(match.group(4))
# Build condition values
condition_values = [{"userEnteredValue": opt} for opt in options]
request = {
"setDataValidation": {
"range": {
"sheetId": sheet_id,
"startRowIndex": start_row,
"endRowIndex": end_row,
"startColumnIndex": start_col,
"endColumnIndex": end_col,
},
"rule": {
"condition": {
"type": "ONE_OF_LIST",
"values": condition_values,
},
"strict": strict,
"showCustomUi": show_dropdown,
},
}
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": [request]}
).execute()
return {"success": True}
class GoogleSheetsCopyToSpreadsheetBlock(Block):
"""Copy a sheet to another spreadsheet."""
class Input(BlockSchemaInput):
source_spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Source Spreadsheet",
description="Select the source spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
source_sheet_name: str = SchemaField(
description="Sheet to copy (optional, defaults to first sheet)",
default="",
)
destination_spreadsheet_id: str = SchemaField(
description="ID of the destination spreadsheet",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the copy operation",
)
new_sheet_id: int = SchemaField(
description="ID of the new sheet in the destination",
)
new_sheet_name: str = SchemaField(
description="Name of the new sheet",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The source spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="740eec3f-2b51-4e95-b87f-22ce2acafdfa",
description="Copy a sheet from one spreadsheet to another.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsCopyToSpreadsheetBlock.Input,
output_schema=GoogleSheetsCopyToSpreadsheetBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"source_spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Source Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"destination_spreadsheet_id": "dest_spreadsheet_id_123",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("new_sheet_id", 12345),
("new_sheet_name", "Copy of Sheet1"),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Source Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_copy_to_spreadsheet": lambda *args, **kwargs: {
"success": True,
"new_sheet_id": 12345,
"new_sheet_name": "Copy of Sheet1",
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.source_spreadsheet:
yield "error", "No source spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.source_spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._copy_to_spreadsheet,
service,
input_data.source_spreadsheet.id,
input_data.source_sheet_name,
input_data.destination_spreadsheet_id,
)
yield "result", {"success": True}
yield "new_sheet_id", result["new_sheet_id"]
yield "new_sheet_name", result["new_sheet_name"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.source_spreadsheet.id,
name=input_data.source_spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.source_spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.source_spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to copy sheet: {str(e)}"
def _copy_to_spreadsheet(
self,
service,
source_spreadsheet_id: str,
source_sheet_name: str,
destination_spreadsheet_id: str,
) -> dict:
target_sheet = resolve_sheet_name(
service, source_spreadsheet_id, source_sheet_name or None
)
sheet_id = sheet_id_by_name(service, source_spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
result = (
service.spreadsheets()
.sheets()
.copyTo(
spreadsheetId=source_spreadsheet_id,
sheetId=sheet_id,
body={"destinationSpreadsheetId": destination_spreadsheet_id},
)
.execute()
)
return {
"success": True,
"new_sheet_id": result.get("sheetId", 0),
"new_sheet_name": result.get("title", ""),
}
class GoogleSheetsProtectRangeBlock(Block):
"""Protect a range from editing."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="Select a Google Sheets spreadsheet",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
description="Sheet name (optional, defaults to first sheet)",
default="",
)
range: str = SchemaField(
description="Cell range to protect (e.g., 'A1:D10'). Leave empty to protect entire sheet.",
default="",
)
description: str = SchemaField(
description="Description for the protected range",
default="Protected by automation",
)
warning_only: bool = SchemaField(
description="Show warning but allow editing (vs blocking completely)",
default=False,
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(
description="Result of the operation",
)
protection_id: int = SchemaField(
description="ID of the protection",
)
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining",
)
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="d0e4f5d1-76e7-4082-9be8-e656ec1f432d",
description="Protect a cell range or entire sheet from editing.",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsProtectRangeBlock.Input,
output_schema=GoogleSheetsProtectRangeBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"range": "A1:D10",
"description": "Header row protection",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("protection_id", 12345),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_protect_range": lambda *args, **kwargs: {
"success": True,
"protection_id": 12345,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._protect_range,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.range,
input_data.description,
input_data.warning_only,
)
yield "result", {"success": True}
yield "protection_id", result["protection_id"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to protect range: {str(e)}"
def _protect_range(
self,
service,
spreadsheet_id: str,
sheet_name: str,
range_str: str,
description: str,
warning_only: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
protected_range: dict = {"sheetId": sheet_id}
if range_str:
# Parse specific range
if "!" in range_str:
range_str = range_str.split("!")[1]
match = re.match(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", range_str.upper())
if not match:
raise ValueError(f"Invalid range format: {range_str}")
protected_range["startRowIndex"] = int(match.group(2)) - 1
protected_range["endRowIndex"] = int(match.group(4))
protected_range["startColumnIndex"] = _column_letter_to_index(
match.group(1)
)
protected_range["endColumnIndex"] = (
_column_letter_to_index(match.group(3)) + 1
)
request = {
"addProtectedRange": {
"protectedRange": {
"range": protected_range,
"description": description,
"warningOnly": warning_only,
}
}
}
result = (
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body={"requests": [request]})
.execute()
)
protection_id = 0
replies = result.get("replies", [])
if replies and "addProtectedRange" in replies[0]:
protection_id = replies[0]["addProtectedRange"]["protectedRange"][
"protectedRangeId"
]
return {"success": True, "protection_id": protection_id}
class GoogleSheetsExportCsvBlock(Block):
"""Export a sheet as CSV data."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="The spreadsheet to export from",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
sheet_name: str = SchemaField(
default="",
description="Name of the sheet to export. Defaults to first sheet.",
)
include_headers: bool = SchemaField(
default=True,
description="Include the first row (headers) in the CSV output",
)
class Output(BlockSchemaOutput):
csv_data: str = SchemaField(description="The sheet data as CSV string")
row_count: int = SchemaField(description="Number of rows exported")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining"
)
error: str = SchemaField(description="Error message if export failed")
def __init__(self):
super().__init__(
id="2617e68a-43b3-441f-8b11-66bb041105b8",
description="Export a Google Sheet as CSV data",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsExportCsvBlock.Input,
output_schema=GoogleSheetsExportCsvBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("csv_data", "Name,Email,Status\nJohn,john@test.com,Active\n"),
("row_count", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_export_csv": lambda *args, **kwargs: {
"csv_data": "Name,Email,Status\nJohn,john@test.com,Active\n",
"row_count": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._export_csv,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.include_headers,
)
yield "csv_data", result["csv_data"]
yield "row_count", result["row_count"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to export CSV: {str(e)}"
def _export_csv(
self,
service,
spreadsheet_id: str,
sheet_name: str,
include_headers: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
range_name = f"'{target_sheet}'"
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=spreadsheet_id, range=range_name)
.execute()
)
rows = result.get("values", [])
# Skip header row if not including headers
if not include_headers and rows:
rows = rows[1:]
output = io.StringIO()
writer = csv.writer(output)
for row in rows:
writer.writerow(row)
csv_data = output.getvalue()
return {"csv_data": csv_data, "row_count": len(rows)}
class GoogleSheetsImportCsvBlock(Block):
"""Import CSV data into a sheet."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="The spreadsheet to import into",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
csv_data: str = SchemaField(description="CSV data to import")
sheet_name: str = SchemaField(
default="",
description="Name of the sheet. Defaults to first sheet.",
)
start_cell: str = SchemaField(
default="A1",
description="Cell to start importing at (e.g., A1, B2)",
)
clear_existing: bool = SchemaField(
default=False,
description="Clear existing data before importing",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Import result")
rows_imported: int = SchemaField(description="Number of rows imported")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining"
)
error: str = SchemaField(description="Error message if import failed")
def __init__(self):
super().__init__(
id="cb992884-1ff2-450a-8f1b-7650d63e3aa0",
description="Import CSV data into a Google Sheet",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsImportCsvBlock.Input,
output_schema=GoogleSheetsImportCsvBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"csv_data": "Name,Email,Status\nJohn,john@test.com,Active\n",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
("rows_imported", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_import_csv": lambda *args, **kwargs: {
"success": True,
"rows_imported": 2,
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._import_csv,
service,
input_data.spreadsheet.id,
input_data.csv_data,
input_data.sheet_name,
input_data.start_cell,
input_data.clear_existing,
)
yield "result", {"success": True}
yield "rows_imported", result["rows_imported"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to import CSV: {str(e)}"
def _import_csv(
self,
service,
spreadsheet_id: str,
csv_data: str,
sheet_name: str,
start_cell: str,
clear_existing: bool,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
# Parse CSV data
reader = csv.reader(io.StringIO(csv_data))
rows = list(reader)
if not rows:
return {"success": True, "rows_imported": 0}
# Clear existing data if requested
if clear_existing:
service.spreadsheets().values().clear(
spreadsheetId=spreadsheet_id,
range=f"'{target_sheet}'",
).execute()
# Write data
range_name = f"'{target_sheet}'!{start_cell}"
service.spreadsheets().values().update(
spreadsheetId=spreadsheet_id,
range=range_name,
valueInputOption="RAW",
body={"values": rows},
).execute()
return {"success": True, "rows_imported": len(rows)}
class GoogleSheetsAddNoteBlock(Block):
"""Add a note (comment) to a cell."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="The spreadsheet to add note to",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
cell: str = SchemaField(
description="Cell to add note to (e.g., A1, B2)",
)
note: str = SchemaField(description="Note text to add")
sheet_name: str = SchemaField(
default="",
description="Name of the sheet. Defaults to first sheet.",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Result of the operation")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining"
)
error: str = SchemaField(description="Error message if operation failed")
def __init__(self):
super().__init__(
id="774ac529-74f9-41da-bbba-6a06a51a5d7e",
description="Add a note to a cell in a Google Sheet",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsAddNoteBlock.Input,
output_schema=GoogleSheetsAddNoteBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"cell": "A1",
"note": "This is a test note",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_add_note": lambda *args, **kwargs: {"success": True},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
await asyncio.to_thread(
self._add_note,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.cell,
input_data.note,
)
yield "result", {"success": True}
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to add note: {str(e)}"
def _add_note(
self,
service,
spreadsheet_id: str,
sheet_name: str,
cell: str,
note: str,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
sheet_id = sheet_id_by_name(service, spreadsheet_id, target_sheet)
if sheet_id is None:
raise ValueError(f"Sheet '{target_sheet}' not found")
# Parse cell reference
match = re.match(r"([A-Z]+)(\d+)", cell.upper())
if not match:
raise ValueError(f"Invalid cell reference: {cell}")
col_index = _column_letter_to_index(match.group(1))
row_index = int(match.group(2)) - 1
request = {
"updateCells": {
"rows": [{"values": [{"note": note}]}],
"fields": "note",
"start": {
"sheetId": sheet_id,
"rowIndex": row_index,
"columnIndex": col_index,
},
}
}
service.spreadsheets().batchUpdate(
spreadsheetId=spreadsheet_id, body={"requests": [request]}
).execute()
return {"success": True}
class GoogleSheetsGetNotesBlock(Block):
"""Get notes from cells in a range."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="The spreadsheet to get notes from",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
range: str = SchemaField(
default="A1:Z100",
description="Range to get notes from (e.g., A1:B10)",
)
sheet_name: str = SchemaField(
default="",
description="Name of the sheet. Defaults to first sheet.",
)
class Output(BlockSchemaOutput):
notes: list[dict] = SchemaField(description="List of notes with cell and text")
count: int = SchemaField(description="Number of notes found")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining"
)
error: str = SchemaField(description="Error message if operation failed")
def __init__(self):
super().__init__(
id="fa16834f-fff4-4d7a-9f7f-531ced90492b",
description="Get notes from cells in a Google Sheet",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsGetNotesBlock.Input,
output_schema=GoogleSheetsGetNotesBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"notes",
[
{"cell": "A1", "note": "Header note"},
{"cell": "B2", "note": "Data note"},
],
),
("count", 2),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_get_notes": lambda *args, **kwargs: {
"notes": [
{"cell": "A1", "note": "Header note"},
{"cell": "B2", "note": "Data note"},
],
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_sheets_service(credentials)
result = await asyncio.to_thread(
self._get_notes,
service,
input_data.spreadsheet.id,
input_data.sheet_name,
input_data.range,
)
notes = result["notes"]
yield "notes", notes
yield "count", len(notes)
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to get notes: {str(e)}"
def _get_notes(
self,
service,
spreadsheet_id: str,
sheet_name: str,
range_str: str,
) -> dict:
target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name or None)
full_range = f"'{target_sheet}'!{range_str}"
# Get spreadsheet data including notes
result = (
service.spreadsheets()
.get(
spreadsheetId=spreadsheet_id,
ranges=[full_range],
includeGridData=True,
)
.execute()
)
notes = []
sheets = result.get("sheets", [])
for sheet in sheets:
data = sheet.get("data", [])
for grid_data in data:
start_row = grid_data.get("startRow", 0)
start_col = grid_data.get("startColumn", 0)
row_data = grid_data.get("rowData", [])
for row_idx, row in enumerate(row_data):
values = row.get("values", [])
for col_idx, cell in enumerate(values):
note = cell.get("note")
if note:
col_letter = _index_to_column_letter(start_col + col_idx)
cell_ref = f"{col_letter}{start_row + row_idx + 1}"
notes.append({"cell": cell_ref, "note": note})
return {"notes": notes}
class GoogleSheetsShareSpreadsheetBlock(Block):
"""Share a spreadsheet with specific users or make it accessible."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="The spreadsheet to share",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
email: str = SchemaField(
default="",
description="Email address to share with. Leave empty for link sharing.",
)
role: ShareRole = SchemaField(
default=ShareRole.READER,
description="Permission role for the user",
)
send_notification: bool = SchemaField(
default=True,
description="Send notification email to the user",
)
message: str = SchemaField(
default="",
description="Optional message to include in notification email",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Result of the share operation")
share_link: str = SchemaField(description="Link to the spreadsheet")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining"
)
error: str = SchemaField(description="Error message if share failed")
def __init__(self):
super().__init__(
id="3e47e8ac-511a-4eb6-89c5-a6bcedc4236f",
description="Share a Google Spreadsheet with users or get shareable link",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsShareSpreadsheetBlock.Input,
output_schema=GoogleSheetsShareSpreadsheetBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"email": "test@example.com",
"role": "reader",
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True}),
(
"share_link",
"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_share_spreadsheet": lambda *args, **kwargs: {
"success": True,
"share_link": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_drive_service(credentials)
result = await asyncio.to_thread(
self._share_spreadsheet,
service,
input_data.spreadsheet.id,
input_data.email,
input_data.role,
input_data.send_notification,
input_data.message,
)
yield "result", {"success": True}
yield "share_link", result["share_link"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to share spreadsheet: {str(e)}"
def _share_spreadsheet(
self,
service,
spreadsheet_id: str,
email: str,
role: ShareRole,
send_notification: bool,
message: str,
) -> dict:
share_link = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit"
if email:
# Share with specific user
permission = {"type": "user", "role": role.value, "emailAddress": email}
kwargs: dict = {
"fileId": spreadsheet_id,
"body": permission,
"sendNotificationEmail": send_notification,
}
if message:
kwargs["emailMessage"] = message
service.permissions().create(**kwargs).execute()
else:
# Get shareable link - use reader or commenter only (writer not allowed for "anyone")
link_role = "reader" if role == ShareRole.WRITER else role.value
permission = {"type": "anyone", "role": link_role}
service.permissions().create(
fileId=spreadsheet_id, body=permission
).execute()
share_link += "?usp=sharing"
return {"success": True, "share_link": share_link}
class GoogleSheetsSetPublicAccessBlock(Block):
"""Make a spreadsheet publicly accessible or private."""
class Input(BlockSchemaInput):
spreadsheet: GoogleDriveFile = GoogleDriveFileField(
title="Spreadsheet",
description="The spreadsheet to modify access for",
credentials_kwarg="credentials",
allowed_views=["SPREADSHEETS"],
allowed_mime_types=["application/vnd.google-apps.spreadsheet"],
)
public: bool = SchemaField(
default=True,
description="True to make public, False to make private",
)
role: PublicAccessRole = SchemaField(
default=PublicAccessRole.READER,
description="Permission role for public access",
)
class Output(BlockSchemaOutput):
result: dict = SchemaField(description="Result of the operation")
share_link: str = SchemaField(description="Link to the spreadsheet")
spreadsheet: GoogleDriveFile = SchemaField(
description="The spreadsheet for chaining"
)
error: str = SchemaField(description="Error message if operation failed")
def __init__(self):
super().__init__(
id="d08d46cd-088b-4ba7-a545-45050f33b889",
description="Make a Google Spreadsheet public or private",
categories={BlockCategory.DATA},
input_schema=GoogleSheetsSetPublicAccessBlock.Input,
output_schema=GoogleSheetsSetPublicAccessBlock.Output,
disabled=GOOGLE_SHEETS_DISABLED,
test_input={
"spreadsheet": {
"id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"name": "Test Spreadsheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
},
"public": True,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("result", {"success": True, "is_public": True}),
(
"share_link",
"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit?usp=sharing",
),
(
"spreadsheet",
GoogleDriveFile(
id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
name="Test Spreadsheet",
mimeType="application/vnd.google-apps.spreadsheet",
url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=None,
),
),
],
test_mock={
"_set_public_access": lambda *args, **kwargs: {
"success": True,
"is_public": True,
"share_link": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit?usp=sharing",
},
},
)
async def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
if not input_data.spreadsheet:
yield "error", "No spreadsheet selected"
return
validation_error = _validate_spreadsheet_file(input_data.spreadsheet)
if validation_error:
yield "error", validation_error
return
try:
service = _build_drive_service(credentials)
result = await asyncio.to_thread(
self._set_public_access,
service,
input_data.spreadsheet.id,
input_data.public,
input_data.role,
)
yield "result", {"success": True, "is_public": result["is_public"]}
yield "share_link", result["share_link"]
yield "spreadsheet", GoogleDriveFile(
id=input_data.spreadsheet.id,
name=input_data.spreadsheet.name,
mimeType="application/vnd.google-apps.spreadsheet",
url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit",
iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
isFolder=False,
_credentials_id=input_data.spreadsheet.credentials_id,
)
except Exception as e:
yield "error", f"Failed to set public access: {str(e)}"
def _set_public_access(
self,
service,
spreadsheet_id: str,
public: bool,
role: PublicAccessRole,
) -> dict:
share_link = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit"
if public:
# Make public
permission = {"type": "anyone", "role": role.value}
service.permissions().create(
fileId=spreadsheet_id, body=permission
).execute()
share_link += "?usp=sharing"
else:
# Make private - remove 'anyone' permissions
permissions = service.permissions().list(fileId=spreadsheet_id).execute()
for perm in permissions.get("permissions", []):
if perm.get("type") == "anyone":
service.permissions().delete(
fileId=spreadsheet_id, permissionId=perm["id"]
).execute()
return {"success": True, "is_public": public, "share_link": share_link}