This commit is contained in:
Taylor Wilsdon
2026-02-17 14:49:49 -05:00
38 changed files with 2232 additions and 489 deletions

View File

@@ -392,7 +392,7 @@ async def modify_doc_text(
"""
logger.info(
f"[modify_doc_text] Doc={document_id}, start={start_index}, end={end_index}, text={text is not None}, "
f"formatting={any([bold, italic, underline, font_size, font_family, text_color, background_color, link_url])}"
f"formatting={any(p is not None for p in [bold, italic, underline, font_size, font_family, text_color, background_color, link_url])}"
)
# Input validation
@@ -403,32 +403,12 @@ async def modify_doc_text(
return f"Error: {error_msg}"
# Validate that we have something to do
if text is None and not any(
[
bold is not None,
italic is not None,
underline is not None,
font_size,
font_family,
text_color,
background_color,
link_url,
]
):
formatting_params = [bold, italic, underline, font_size, font_family, text_color, background_color, link_url]
if text is None and not any(p is not None for p in formatting_params):
return "Error: Must provide either 'text' to insert/replace, or formatting parameters (bold, italic, underline, font_size, font_family, text_color, background_color, link_url)."
# Validate text formatting params if provided
if any(
[
bold is not None,
italic is not None,
underline is not None,
font_size,
font_family,
text_color,
background_color,
]
):
if any(p is not None for p in formatting_params):
is_valid, error_msg = validator.validate_text_formatting_params(
bold,
italic,
@@ -486,18 +466,7 @@ async def modify_doc_text(
operations.append(f"Inserted text at index {start_index}")
# Handle formatting
if any(
[
bold is not None,
italic is not None,
underline is not None,
font_size,
font_family,
text_color,
background_color,
link_url,
]
):
if any(p is not None for p in formatting_params):
# Adjust range for formatting based on text operations
format_start = start_index
format_end = end_index
@@ -533,23 +502,20 @@ async def modify_doc_text(
)
)
format_details = []
if bold is not None:
format_details.append(f"bold={bold}")
if italic is not None:
format_details.append(f"italic={italic}")
if underline is not None:
format_details.append(f"underline={underline}")
if font_size:
format_details.append(f"font_size={font_size}")
if font_family:
format_details.append(f"font_family={font_family}")
if text_color:
format_details.append(f"text_color={text_color}")
if background_color:
format_details.append(f"background_color={background_color}")
if link_url:
format_details.append(f"link_url={link_url}")
format_details = [
f"{name}={value}"
for name, value in [
("bold", bold),
("italic", italic),
("underline", underline),
("font_size", font_size),
("font_family", font_family),
("text_color", text_color),
("background_color", background_color),
("link_url", link_url),
]
if value is not None
]
operations.append(
f"Applied formatting ({', '.join(format_details)}) to range {format_start}-{format_end}"
@@ -1341,6 +1307,35 @@ async def export_doc_to_pdf(
# ==============================================================================
async def _get_paragraph_start_indices_in_range(
service: Any, document_id: str, start_index: int, end_index: int
) -> list[int]:
"""
Fetch paragraph start indices that overlap a target range.
"""
doc_data = await asyncio.to_thread(
service.documents()
.get(
documentId=document_id,
fields="body/content(startIndex,endIndex,paragraph)",
)
.execute
)
paragraph_starts = []
for element in doc_data.get("body", {}).get("content", []):
if "paragraph" not in element:
continue
paragraph_start = element.get("startIndex")
paragraph_end = element.get("endIndex")
if not isinstance(paragraph_start, int) or not isinstance(paragraph_end, int):
continue
if paragraph_end > start_index and paragraph_start < end_index:
paragraph_starts.append(paragraph_start)
return paragraph_starts or [start_index]
@server.tool()
@handle_http_errors("update_paragraph_style", service_type="docs")
@require_google_service("docs", "docs_write")
@@ -1358,13 +1353,16 @@ async def update_paragraph_style(
indent_end: float = None,
space_above: float = None,
space_below: float = None,
list_type: str = None,
list_nesting_level: int = None,
) -> str:
"""
Apply paragraph-level formatting and/or heading styles to a range in a Google Doc.
Apply paragraph-level formatting, heading styles, and/or list formatting to a range in a Google Doc.
This tool can apply named heading styles (H1-H6) for semantic document structure,
and/or customize paragraph properties like alignment, spacing, and indentation.
Both can be applied in a single operation.
create bulleted or numbered lists with nested indentation, and customize paragraph
properties like alignment, spacing, and indentation. All operations can be applied
in a single call.
Args:
user_google_email: User's Google email address
@@ -1380,6 +1378,9 @@ async def update_paragraph_style(
indent_end: Right/end indent in points
space_above: Space above paragraph in points (e.g., 12 for one line)
space_below: Space below paragraph in points
list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers)
list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0)
Use higher levels for nested/indented list items
Returns:
str: Confirmation message with formatting details
@@ -1388,13 +1389,21 @@ async def update_paragraph_style(
# Apply H1 heading style
update_paragraph_style(document_id="...", start_index=1, end_index=20, heading_level=1)
# Center-align a paragraph with double spacing
# Create a bulleted list
update_paragraph_style(document_id="...", start_index=1, end_index=50,
alignment="CENTER", line_spacing=2.0)
list_type="UNORDERED")
# Create a nested numbered list item
update_paragraph_style(document_id="...", start_index=1, end_index=30,
list_type="ORDERED", list_nesting_level=1)
# Apply H2 heading with custom spacing
update_paragraph_style(document_id="...", start_index=1, end_index=30,
heading_level=2, space_above=18, space_below=12)
# Center-align a paragraph with double spacing
update_paragraph_style(document_id="...", start_index=1, end_index=50,
alignment="CENTER", line_spacing=2.0)
"""
logger.info(
f"[update_paragraph_style] Doc={document_id}, Range: {start_index}-{end_index}"
@@ -1406,6 +1415,27 @@ async def update_paragraph_style(
if end_index <= start_index:
return "Error: end_index must be greater than start_index"
# Validate list parameters
list_type_value = list_type
if list_type_value is not None:
# Coerce non-string inputs to string before normalization to avoid AttributeError
if not isinstance(list_type_value, str):
list_type_value = str(list_type_value)
valid_list_types = ["UNORDERED", "ORDERED"]
normalized_list_type = list_type_value.upper()
if normalized_list_type not in valid_list_types:
return f"Error: list_type must be one of: {', '.join(valid_list_types)}"
list_type_value = normalized_list_type
if list_nesting_level is not None:
if list_type_value is None:
return "Error: list_nesting_level requires list_type parameter"
if not isinstance(list_nesting_level, int):
return "Error: list_nesting_level must be an integer"
if list_nesting_level < 0 or list_nesting_level > 8:
return "Error: list_nesting_level must be between 0 and 8"
# Build paragraph style object
paragraph_style = {}
fields = []
@@ -1461,19 +1491,45 @@ async def update_paragraph_style(
paragraph_style["spaceBelow"] = {"magnitude": space_below, "unit": "PT"}
fields.append("spaceBelow")
if not paragraph_style:
return f"No paragraph style changes specified for document {document_id}"
# Create batch update requests
requests = []
# Create batch update request
requests = [
{
"updateParagraphStyle": {
"range": {"startIndex": start_index, "endIndex": end_index},
"paragraphStyle": paragraph_style,
"fields": ",".join(fields),
# Add paragraph style update if we have any style changes
if paragraph_style:
requests.append(
{
"updateParagraphStyle": {
"range": {"startIndex": start_index, "endIndex": end_index},
"paragraphStyle": paragraph_style,
"fields": ",".join(fields),
}
}
}
]
)
# Add list creation if requested
if list_type_value is not None:
# Default to level 0 if not specified
nesting_level = list_nesting_level if list_nesting_level is not None else 0
try:
paragraph_start_indices = None
if nesting_level > 0:
paragraph_start_indices = await _get_paragraph_start_indices_in_range(
service, document_id, start_index, end_index
)
list_requests = create_bullet_list_request(
start_index,
end_index,
list_type_value,
nesting_level,
paragraph_start_indices=paragraph_start_indices,
)
requests.extend(list_requests)
except ValueError as e:
return f"Error: {e}"
# Validate we have at least one operation
if not requests:
return f"No paragraph style changes or list creation specified for document {document_id}"
await asyncio.to_thread(
service.documents()
@@ -1488,9 +1544,14 @@ async def update_paragraph_style(
format_fields = [f for f in fields if f != "namedStyleType"]
if format_fields:
summary_parts.append(", ".join(format_fields))
if list_type_value is not None:
list_desc = f"{list_type_value.lower()} list"
if list_nesting_level is not None and list_nesting_level > 0:
list_desc += f" (level {list_nesting_level})"
summary_parts.append(list_desc)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Applied paragraph style ({', '.join(summary_parts)}) to range {start_index}-{end_index} in document {document_id}. Link: {link}"
return f"Applied paragraph formatting ({', '.join(summary_parts)}) to range {start_index}-{end_index} in document {document_id}. Link: {link}"
# Create comment management tools for documents