This commit is contained in:
Taylor Wilsdon
2026-02-08 18:15:36 -05:00
parent 02a0d214e5
commit be6a42e6e5
4 changed files with 124 additions and 42 deletions

View File

@@ -283,21 +283,27 @@ def create_bullet_list_request(
end_index: int,
list_type: str = "UNORDERED",
nesting_level: int = None,
paragraph_start_indices: Optional[list[int]] = None,
) -> list[Dict[str, Any]]:
"""
Create requests to apply bullet list formatting with optional nesting.
The Google Docs API determines nesting level by counting leading tabs.
If nesting_level > 0, this function prepends tab characters before creating bullets.
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:
List of request dictionaries (insertText for tabs if needed, then createParagraphBullets)
List of request dictionaries (insertText for nesting tabs if needed,
then createParagraphBullets)
"""
bullet_preset = (
"BULLET_DISC_CIRCLE_SQUARE"
@@ -314,19 +320,39 @@ def create_bullet_list_request(
requests = []
# Insert tabs for nesting if needed (nesting_level > 0)
# 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
requests.append(
{
"insertText": {
"location": {"index": start_index},
"text": tabs,
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
)
# Adjust end_index to account for inserted tabs
end_index += nesting_level
# Create the bullet list
requests.append(

View File

@@ -1333,6 +1333,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")
@@ -1350,7 +1379,7 @@ async def update_paragraph_style(
indent_end: float = None,
space_above: float = None,
space_below: float = None,
create_list: str = None,
list_type: str = None,
list_nesting_level: int = None,
) -> str:
"""
@@ -1375,7 +1404,7 @@ 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
create_list: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers)
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
@@ -1388,11 +1417,11 @@ async def update_paragraph_style(
# Create a bulleted list
update_paragraph_style(document_id="...", start_index=1, end_index=50,
create_list="UNORDERED")
list_type="UNORDERED")
# Create a nested numbered list item
update_paragraph_style(document_id="...", start_index=1, end_index=30,
create_list="ORDERED", list_nesting_level=1)
list_type="ORDERED", list_nesting_level=1)
# Apply H2 heading with custom spacing
update_paragraph_style(document_id="...", start_index=1, end_index=30,
@@ -1413,19 +1442,21 @@ async def update_paragraph_style(
return "Error: end_index must be greater than start_index"
# Validate list parameters
if create_list is not None:
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(create_list, str):
create_list = str(create_list)
if not isinstance(list_type_value, str):
list_type_value = str(list_type_value)
valid_list_types = ["UNORDERED", "ORDERED"]
normalized_create_list = create_list.upper()
if normalized_create_list not in valid_list_types:
return f"Error: create_list must be one of: {', '.join(valid_list_types)}"
create_list = normalized_create_list
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 create_list is None:
return "Error: list_nesting_level requires create_list parameter"
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:
@@ -1502,12 +1533,21 @@ async def update_paragraph_style(
)
# Add list creation if requested
if create_list is not None:
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, create_list, nesting_level
start_index,
end_index,
list_type_value,
nesting_level,
paragraph_start_indices=paragraph_start_indices,
)
requests.extend(list_requests)
except ValueError as e:
@@ -1530,8 +1570,8 @@ 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 create_list is not None:
list_desc = f"{create_list.lower()} list"
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)