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

@@ -104,6 +104,86 @@ def build_text_style(
return text_style, fields
def build_paragraph_style(
heading_level: int = None,
alignment: str = None,
line_spacing: float = None,
indent_first_line: float = None,
indent_start: float = None,
indent_end: float = None,
space_above: float = None,
space_below: float = None,
) -> tuple[Dict[str, Any], list[str]]:
"""
Build paragraph style object for Google Docs API requests.
Args:
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N)
alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED'
line_spacing: Line spacing multiplier (1.0 = single, 2.0 = double)
indent_first_line: First line indent in points
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points
space_below: Space below paragraph in points
Returns:
Tuple of (paragraph_style_dict, list_of_field_names)
"""
paragraph_style = {}
fields = []
if heading_level is not None:
if heading_level < 0 or heading_level > 6:
raise ValueError("heading_level must be between 0 (normal text) and 6")
if heading_level == 0:
paragraph_style["namedStyleType"] = "NORMAL_TEXT"
else:
paragraph_style["namedStyleType"] = f"HEADING_{heading_level}"
fields.append("namedStyleType")
if alignment is not None:
valid_alignments = ["START", "CENTER", "END", "JUSTIFIED"]
alignment_upper = alignment.upper()
if alignment_upper not in valid_alignments:
raise ValueError(
f"Invalid alignment '{alignment}'. Must be one of: {valid_alignments}"
)
paragraph_style["alignment"] = alignment_upper
fields.append("alignment")
if line_spacing is not None:
if line_spacing <= 0:
raise ValueError("line_spacing must be positive")
paragraph_style["lineSpacing"] = line_spacing * 100
fields.append("lineSpacing")
if indent_first_line is not None:
paragraph_style["indentFirstLine"] = {
"magnitude": indent_first_line,
"unit": "PT",
}
fields.append("indentFirstLine")
if indent_start is not None:
paragraph_style["indentStart"] = {"magnitude": indent_start, "unit": "PT"}
fields.append("indentStart")
if indent_end is not None:
paragraph_style["indentEnd"] = {"magnitude": indent_end, "unit": "PT"}
fields.append("indentEnd")
if space_above is not None:
paragraph_style["spaceAbove"] = {"magnitude": space_above, "unit": "PT"}
fields.append("spaceAbove")
if space_below is not None:
paragraph_style["spaceBelow"] = {"magnitude": space_below, "unit": "PT"}
fields.append("spaceBelow")
return paragraph_style, fields
def create_insert_text_request(index: int, text: str) -> Dict[str, Any]:
"""
Create an insertText request for Google Docs API.
@@ -211,6 +291,59 @@ def create_format_text_request(
}
def create_update_paragraph_style_request(
start_index: int,
end_index: int,
heading_level: int = None,
alignment: str = None,
line_spacing: float = None,
indent_first_line: float = None,
indent_start: float = None,
indent_end: float = None,
space_above: float = None,
space_below: float = None,
) -> Optional[Dict[str, Any]]:
"""
Create an updateParagraphStyle request for Google Docs API.
Args:
start_index: Start position of paragraph range
end_index: End position of paragraph range
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N)
alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED'
line_spacing: Line spacing multiplier (1.0 = single, 2.0 = double)
indent_first_line: First line indent in points
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points
space_below: Space below paragraph in points
Returns:
Dictionary representing the updateParagraphStyle request, or None if no styles provided
"""
paragraph_style, fields = build_paragraph_style(
heading_level,
alignment,
line_spacing,
indent_first_line,
indent_start,
indent_end,
space_above,
space_below,
)
if not paragraph_style:
return None
return {
"updateParagraphStyle": {
"range": {"startIndex": start_index, "endIndex": end_index},
"paragraphStyle": paragraph_style,
"fields": ",".join(fields),
}
}
def create_find_replace_request(
find_text: str, replace_text: str, match_case: bool = False
) -> Dict[str, Any]:
@@ -294,18 +427,31 @@ def create_insert_image_request(
def create_bullet_list_request(
start_index: int, end_index: int, list_type: str = "UNORDERED"
) -> Dict[str, Any]:
start_index: int,
end_index: int,
list_type: str = "UNORDERED",
nesting_level: int = None,
paragraph_start_indices: Optional[list[int]] = None,
) -> list[Dict[str, Any]]:
"""
Create a createParagraphBullets request for Google Docs API.
Create requests to apply bullet list formatting with optional nesting.
Google Docs infers list nesting from leading tab characters. To set a nested
level, this helper inserts literal tab characters before each targeted
paragraph, then calls createParagraphBullets. This is a Docs API workaround
and does temporarily mutate content/index positions while the batch executes.
Args:
start_index: Start of text range to convert to list
end_index: End of text range to convert to list
list_type: Type of list ("UNORDERED" or "ORDERED")
nesting_level: Nesting level (0-8, where 0 is top level). If None or 0, no tabs added.
paragraph_start_indices: Optional paragraph start positions for ranges with
multiple paragraphs. If omitted, only start_index is tab-prefixed.
Returns:
Dictionary representing the createParagraphBullets request
List of request dictionaries (insertText for nesting tabs if needed,
then createParagraphBullets)
"""
bullet_preset = (
"BULLET_DISC_CIRCLE_SQUARE"
@@ -313,12 +459,60 @@ def create_bullet_list_request(
else "NUMBERED_DECIMAL_ALPHA_ROMAN"
)
return {
"createParagraphBullets": {
"range": {"startIndex": start_index, "endIndex": end_index},
"bulletPreset": bullet_preset,
# Validate nesting level
if nesting_level is not None:
if not isinstance(nesting_level, int):
raise ValueError("nesting_level must be an integer between 0 and 8")
if nesting_level < 0 or nesting_level > 8:
raise ValueError("nesting_level must be between 0 and 8")
requests = []
# Insert tabs for nesting if needed (nesting_level > 0).
# For multi-paragraph ranges, callers should provide paragraph_start_indices.
if nesting_level and nesting_level > 0:
tabs = "\t" * nesting_level
paragraph_starts = paragraph_start_indices or [start_index]
paragraph_starts = sorted(set(paragraph_starts))
if any(not isinstance(idx, int) for idx in paragraph_starts):
raise ValueError("paragraph_start_indices must contain only integers")
original_start = start_index
original_end = end_index
inserted_char_count = 0
for paragraph_start in paragraph_starts:
adjusted_start = paragraph_start + inserted_char_count
requests.append(
{
"insertText": {
"location": {"index": adjusted_start},
"text": tabs,
}
}
)
inserted_char_count += nesting_level
# Keep createParagraphBullets range aligned to the same logical content.
start_index += (
sum(1 for idx in paragraph_starts if idx < original_start) * nesting_level
)
end_index += (
sum(1 for idx in paragraph_starts if idx < original_end) * nesting_level
)
# Create the bullet list
requests.append(
{
"createParagraphBullets": {
"range": {"startIndex": start_index, "endIndex": end_index},
"bulletPreset": bullet_preset,
}
}
}
)
return requests
def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]:
@@ -341,6 +535,7 @@ def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]:
"delete_text": ["start_index", "end_index"],
"replace_text": ["start_index", "end_index", "text"],
"format_text": ["start_index", "end_index"],
"update_paragraph_style": ["start_index", "end_index"],
"insert_table": ["index", "rows", "columns"],
"insert_page_break": ["index"],
"find_replace": ["find_text", "replace_text"],

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

View File

@@ -13,6 +13,7 @@ from gdocs.docs_helpers import (
create_insert_text_request,
create_delete_range_request,
create_format_text_request,
create_update_paragraph_style_request,
create_find_replace_request,
create_insert_table_request,
create_insert_page_break_request,
@@ -213,6 +214,59 @@ class BatchOperationManager:
description = f"format text {op['start_index']}-{op['end_index']} ({', '.join(format_changes)})"
elif op_type == "update_paragraph_style":
request = create_update_paragraph_style_request(
op["start_index"],
op["end_index"],
op.get("heading_level"),
op.get("alignment"),
op.get("line_spacing"),
op.get("indent_first_line"),
op.get("indent_start"),
op.get("indent_end"),
op.get("space_above"),
op.get("space_below"),
)
if not request:
raise ValueError("No paragraph style options provided")
_PT_PARAMS = {
"indent_first_line",
"indent_start",
"indent_end",
"space_above",
"space_below",
}
_SUFFIX = {
"heading_level": lambda v: f"H{v}",
"line_spacing": lambda v: f"{v}x",
}
style_changes = []
for param, name in [
("heading_level", "heading"),
("alignment", "alignment"),
("line_spacing", "line spacing"),
("indent_first_line", "first line indent"),
("indent_start", "start indent"),
("indent_end", "end indent"),
("space_above", "space above"),
("space_below", "space below"),
]:
if op.get(param) is not None:
raw = op[param]
fmt = _SUFFIX.get(param)
if fmt:
value = fmt(raw)
elif param in _PT_PARAMS:
value = f"{raw}pt"
else:
value = raw
style_changes.append(f"{name}: {value}")
description = f"paragraph style {op['start_index']}-{op['end_index']} ({', '.join(style_changes)})"
elif op_type == "insert_table":
request = create_insert_table_request(
op["index"], op["rows"], op["columns"]
@@ -235,6 +289,7 @@ class BatchOperationManager:
"delete_text",
"replace_text",
"format_text",
"update_paragraph_style",
"insert_table",
"insert_page_break",
"find_replace",
@@ -320,6 +375,20 @@ class BatchOperationManager:
],
"description": "Apply formatting to text range",
},
"update_paragraph_style": {
"required": ["start_index", "end_index"],
"optional": [
"heading_level",
"alignment",
"line_spacing",
"indent_first_line",
"indent_start",
"indent_end",
"space_above",
"space_below",
],
"description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)",
},
"insert_table": {
"required": ["index", "rows", "columns"],
"description": "Insert table at specified index",
@@ -343,5 +412,12 @@ class BatchOperationManager:
"bold": True,
},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3},
{
"type": "update_paragraph_style",
"start_index": 1,
"end_index": 20,
"heading_level": 1,
"alignment": "CENTER",
},
],
}

View File

@@ -7,6 +7,7 @@ extracting validation patterns from individual tool functions.
import logging
from typing import Dict, Any, List, Tuple, Optional
from urllib.parse import urlparse
from gdocs.docs_helpers import validate_operation
@@ -38,6 +39,8 @@ class ValidationManager:
"valid_section_types": ["header", "footer"],
"valid_list_types": ["UNORDERED", "ORDERED"],
"valid_element_types": ["table", "list", "page_break"],
"valid_alignments": ["START", "CENTER", "END", "JUSTIFIED"],
"heading_level_range": (0, 6),
}
def validate_document_id(self, document_id: str) -> Tuple[bool, str]:
@@ -258,10 +261,112 @@ class ValidationManager:
if not link_url.strip():
return False, "link_url cannot be empty"
if not (link_url.startswith("http://") or link_url.startswith("https://")):
parsed = urlparse(link_url)
if parsed.scheme not in ("http", "https"):
return False, "link_url must start with http:// or https://"
if not parsed.netloc:
return False, "link_url must include a valid host"
return True, ""
def validate_paragraph_style_params(
self,
heading_level: Optional[int] = None,
alignment: Optional[str] = None,
line_spacing: Optional[float] = None,
indent_first_line: Optional[float] = None,
indent_start: Optional[float] = None,
indent_end: Optional[float] = None,
space_above: Optional[float] = None,
space_below: Optional[float] = None,
) -> Tuple[bool, str]:
"""
Validate paragraph style parameters.
Args:
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N)
alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED'
line_spacing: Line spacing multiplier (must be positive)
indent_first_line: First line indent in points
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points
space_below: Space below paragraph in points
Returns:
Tuple of (is_valid, error_message)
"""
style_params = [
heading_level,
alignment,
line_spacing,
indent_first_line,
indent_start,
indent_end,
space_above,
space_below,
]
if all(param is None for param in style_params):
return (
False,
"At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, or space_below)",
)
if heading_level is not None:
if not isinstance(heading_level, int):
return (
False,
f"heading_level must be an integer, got {type(heading_level).__name__}",
)
min_level, max_level = self.validation_rules["heading_level_range"]
if not (min_level <= heading_level <= max_level):
return (
False,
f"heading_level must be between {min_level} and {max_level}, got {heading_level}",
)
if alignment is not None:
if not isinstance(alignment, str):
return (
False,
f"alignment must be a string, got {type(alignment).__name__}",
)
valid = self.validation_rules["valid_alignments"]
if alignment.upper() not in valid:
return (
False,
f"alignment must be one of: {', '.join(valid)}, got '{alignment}'",
)
if line_spacing is not None:
if not isinstance(line_spacing, (int, float)):
return (
False,
f"line_spacing must be a number, got {type(line_spacing).__name__}",
)
if line_spacing <= 0:
return False, "line_spacing must be positive"
for param, name in [
(indent_first_line, "indent_first_line"),
(indent_start, "indent_start"),
(indent_end, "indent_end"),
(space_above, "space_above"),
(space_below, "space_below"),
]:
if param is not None:
if not isinstance(param, (int, float)):
return (
False,
f"{name} must be a number, got {type(param).__name__}",
)
# indent_first_line may be negative (hanging indent)
if name != "indent_first_line" and param < 0:
return False, f"{name} must be non-negative, got {param}"
return True, ""
def validate_color_param(
self, color: Optional[str], param_name: str
) -> Tuple[bool, str]:
@@ -512,6 +617,32 @@ class ValidationManager:
if not is_valid:
return False, f"Operation {i + 1} (format_text): {error_msg}"
elif op_type == "update_paragraph_style":
is_valid, error_msg = self.validate_paragraph_style_params(
op.get("heading_level"),
op.get("alignment"),
op.get("line_spacing"),
op.get("indent_first_line"),
op.get("indent_start"),
op.get("indent_end"),
op.get("space_above"),
op.get("space_below"),
)
if not is_valid:
return (
False,
f"Operation {i + 1} (update_paragraph_style): {error_msg}",
)
is_valid, error_msg = self.validate_index_range(
op["start_index"], op["end_index"]
)
if not is_valid:
return (
False,
f"Operation {i + 1} (update_paragraph_style): {error_msg}",
)
return True, ""
def validate_text_content(
@@ -547,7 +678,12 @@ class ValidationManager:
"constraints": self.validation_rules.copy(),
"supported_operations": {
"table_operations": ["create_table", "populate_table"],
"text_operations": ["insert_text", "format_text", "find_replace"],
"text_operations": [
"insert_text",
"format_text",
"find_replace",
"update_paragraph_style",
],
"element_operations": [
"insert_table",
"insert_list",