Add support for GDoc Tabs

This commit is contained in:
Vishal Verma
2026-03-01 10:40:19 -08:00
parent 04b9ae027c
commit 10cdcdd7e5
3 changed files with 395 additions and 56 deletions

View File

@@ -184,22 +184,28 @@ def build_paragraph_style(
return paragraph_style, fields return paragraph_style, fields
def create_insert_text_request(index: int, text: str) -> Dict[str, Any]: def create_insert_text_request(
index: int, text: str, tab_id: Optional[str] = None
) -> Dict[str, Any]:
""" """
Create an insertText request for Google Docs API. Create an insertText request for Google Docs API.
Args: Args:
index: Position to insert text index: Position to insert text
text: Text to insert text: Text to insert
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the insertText request Dictionary representing the insertText request
""" """
return {"insertText": {"location": {"index": index}, "text": text}} location = {"index": index}
if tab_id:
location["tabId"] = tab_id
return {"insertText": {"location": location, "text": text}}
def create_insert_text_segment_request( def create_insert_text_segment_request(
index: int, text: str, segment_id: str index: int, text: str, segment_id: str, tab_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create an insertText request for Google Docs API with segmentId (for headers/footers). Create an insertText request for Google Docs API with segmentId (for headers/footers).
@@ -208,34 +214,40 @@ def create_insert_text_segment_request(
index: Position to insert text index: Position to insert text
text: Text to insert text: Text to insert
segment_id: Segment ID (for targeting headers/footers) segment_id: Segment ID (for targeting headers/footers)
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the insertText request with segmentId Dictionary representing the insertText request with segmentId and optional tabId
""" """
location = {"segmentId": segment_id, "index": index}
if tab_id:
location["tabId"] = tab_id
return { return {
"insertText": { "insertText": {
"location": {"segmentId": segment_id, "index": index}, "location": location,
"text": text, "text": text,
} }
} }
def create_delete_range_request(start_index: int, end_index: int) -> Dict[str, Any]: def create_delete_range_request(
start_index: int, end_index: int, tab_id: Optional[str] = None
) -> Dict[str, Any]:
""" """
Create a deleteContentRange request for Google Docs API. Create a deleteContentRange request for Google Docs API.
Args: Args:
start_index: Start position of content to delete start_index: Start position of content to delete
end_index: End position of content to delete end_index: End position of content to delete
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the deleteContentRange request Dictionary representing the deleteContentRange request
""" """
return { range_obj = {"startIndex": start_index, "endIndex": end_index}
"deleteContentRange": { if tab_id:
"range": {"startIndex": start_index, "endIndex": end_index} range_obj["tabId"] = tab_id
} return {"deleteContentRange": {"range": range_obj}}
}
def create_format_text_request( def create_format_text_request(
@@ -249,6 +261,7 @@ def create_format_text_request(
text_color: str = None, text_color: str = None,
background_color: str = None, background_color: str = None,
link_url: str = None, link_url: str = None,
tab_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Create an updateTextStyle request for Google Docs API. Create an updateTextStyle request for Google Docs API.
@@ -264,6 +277,7 @@ def create_format_text_request(
text_color: Text color as hex string "#RRGGBB" text_color: Text color as hex string "#RRGGBB"
background_color: Background (highlight) color as hex string "#RRGGBB" background_color: Background (highlight) color as hex string "#RRGGBB"
link_url: Hyperlink URL (http/https) link_url: Hyperlink URL (http/https)
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the updateTextStyle request, or None if no styles provided Dictionary representing the updateTextStyle request, or None if no styles provided
@@ -282,9 +296,13 @@ def create_format_text_request(
if not text_style: if not text_style:
return None return None
range_obj = {"startIndex": start_index, "endIndex": end_index}
if tab_id:
range_obj["tabId"] = tab_id
return { return {
"updateTextStyle": { "updateTextStyle": {
"range": {"startIndex": start_index, "endIndex": end_index}, "range": range_obj,
"textStyle": text_style, "textStyle": text_style,
"fields": ",".join(fields), "fields": ",".join(fields),
} }
@@ -302,6 +320,7 @@ def create_update_paragraph_style_request(
indent_end: float = None, indent_end: float = None,
space_above: float = None, space_above: float = None,
space_below: float = None, space_below: float = None,
tab_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Create an updateParagraphStyle request for Google Docs API. Create an updateParagraphStyle request for Google Docs API.
@@ -317,6 +336,7 @@ def create_update_paragraph_style_request(
indent_end: Right/end indent in points indent_end: Right/end indent in points
space_above: Space above paragraph in points space_above: Space above paragraph in points
space_below: Space below paragraph in points space_below: Space below paragraph in points
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the updateParagraphStyle request, or None if no styles provided Dictionary representing the updateParagraphStyle request, or None if no styles provided
@@ -335,9 +355,13 @@ def create_update_paragraph_style_request(
if not paragraph_style: if not paragraph_style:
return None return None
range_obj = {"startIndex": start_index, "endIndex": end_index}
if tab_id:
range_obj["tabId"] = tab_id
return { return {
"updateParagraphStyle": { "updateParagraphStyle": {
"range": {"startIndex": start_index, "endIndex": end_index}, "range": range_obj,
"paragraphStyle": paragraph_style, "paragraphStyle": paragraph_style,
"fields": ",".join(fields), "fields": ",".join(fields),
} }
@@ -345,7 +369,7 @@ def create_update_paragraph_style_request(
def create_find_replace_request( def create_find_replace_request(
find_text: str, replace_text: str, match_case: bool = False find_text: str, replace_text: str, match_case: bool = False, tab_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a replaceAllText request for Google Docs API. Create a replaceAllText request for Google Docs API.
@@ -354,19 +378,25 @@ def create_find_replace_request(
find_text: Text to find find_text: Text to find
replace_text: Text to replace with replace_text: Text to replace with
match_case: Whether to match case exactly match_case: Whether to match case exactly
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the replaceAllText request Dictionary representing the replaceAllText request
""" """
return { request = {
"replaceAllText": { "replaceAllText": {
"containsText": {"text": find_text, "matchCase": match_case}, "containsText": {"text": find_text, "matchCase": match_case},
"replaceText": replace_text, "replaceText": replace_text,
} }
} }
if tab_id:
request["replaceAllText"]["tabsCriteria"] = {"tabIds": [tab_id]}
return request
def create_insert_table_request(index: int, rows: int, columns: int) -> Dict[str, Any]: def create_insert_table_request(
index: int, rows: int, columns: int, tab_id: Optional[str] = None
) -> Dict[str, Any]:
""" """
Create an insertTable request for Google Docs API. Create an insertTable request for Google Docs API.
@@ -374,30 +404,102 @@ def create_insert_table_request(index: int, rows: int, columns: int) -> Dict[str
index: Position to insert table index: Position to insert table
rows: Number of rows rows: Number of rows
columns: Number of columns columns: Number of columns
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the insertTable request Dictionary representing the insertTable request
""" """
return { location = {"index": index}
"insertTable": {"location": {"index": index}, "rows": rows, "columns": columns} if tab_id:
} location["tabId"] = tab_id
return {"insertTable": {"location": location, "rows": rows, "columns": columns}}
def create_insert_page_break_request(index: int) -> Dict[str, Any]: def create_insert_page_break_request(
index: int, tab_id: Optional[str] = None
) -> Dict[str, Any]:
""" """
Create an insertPageBreak request for Google Docs API. Create an insertPageBreak request for Google Docs API.
Args: Args:
index: Position to insert page break index: Position to insert page break
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the insertPageBreak request Dictionary representing the insertPageBreak request
""" """
return {"insertPageBreak": {"location": {"index": index}}} location = {"index": index}
if tab_id:
location["tabId"] = tab_id
return {"insertPageBreak": {"location": location}}
def create_insert_doc_tab_request(title: str, index: int, parent_tab_id: Optional[str] = None) -> Dict[str, Any]:
"""
Create an addDocumentTab request for Google Docs API.
Args:
title: Title of the new tab
index: Position to insert the tab
parent_tab_id: Optional ID of the parent tab to nest under
Returns:
Dictionary representing the addDocumentTab request
"""
tab_properties: Dict[str, Any] = {
"title": title,
"index": index,
}
if parent_tab_id:
tab_properties["parentTabId"] = parent_tab_id
return {
"addDocumentTab": {
"tabProperties": tab_properties,
}
}
def create_delete_doc_tab_request(tab_id: str) -> Dict[str, Any]:
"""
Create a deleteDocumentTab request for Google Docs API.
Args:
tab_id: ID of the tab to delete
Returns:
Dictionary representing the deleteDocumentTab request
"""
return {"deleteTab": {"tabId": tab_id}}
def create_update_doc_tab_request(tab_id: str, title: str) -> Dict[str, Any]:
"""
Create an updateDocumentTab request for Google Docs API.
Args:
tab_id: ID of the tab to update
title: New title for the tab
Returns:
Dictionary representing the updateDocumentTab request
"""
return {
"updateDocumentTabProperties": {
"tabProperties": {
"tabId": tab_id,
"title": title,
},
"fields": "title",
}
}
def create_insert_image_request( def create_insert_image_request(
index: int, image_uri: str, width: int = None, height: int = None index: int,
image_uri: str,
width: int = None,
height: int = None,
tab_id: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create an insertInlineImage request for Google Docs API. Create an insertInlineImage request for Google Docs API.
@@ -407,11 +509,16 @@ def create_insert_image_request(
image_uri: URI of the image (Drive URL or public URL) image_uri: URI of the image (Drive URL or public URL)
width: Image width in points width: Image width in points
height: Image height in points height: Image height in points
tab_id: Optional ID of the tab to target
Returns: Returns:
Dictionary representing the insertInlineImage request Dictionary representing the insertInlineImage request
""" """
request = {"insertInlineImage": {"location": {"index": index}, "uri": image_uri}} location = {"index": index}
if tab_id:
location["tabId"] = tab_id
request = {"insertInlineImage": {"location": location, "uri": image_uri}}
# Add size properties if specified # Add size properties if specified
object_size = {} object_size = {}
@@ -432,6 +539,7 @@ def create_bullet_list_request(
list_type: str = "UNORDERED", list_type: str = "UNORDERED",
nesting_level: int = None, nesting_level: int = None,
paragraph_start_indices: Optional[list[int]] = None, paragraph_start_indices: Optional[list[int]] = None,
doc_tab_id: Optional[str] = None,
) -> list[Dict[str, Any]]: ) -> list[Dict[str, Any]]:
""" """
Create requests to apply bullet list formatting with optional nesting. Create requests to apply bullet list formatting with optional nesting.
@@ -448,6 +556,7 @@ def create_bullet_list_request(
nesting_level: Nesting level (0-8, where 0 is top level). If None or 0, no tabs added. 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 paragraph_start_indices: Optional paragraph start positions for ranges with
multiple paragraphs. If omitted, only start_index is tab-prefixed. multiple paragraphs. If omitted, only start_index is tab-prefixed.
tab_id: Optional ID of the tab to target
Returns: Returns:
List of request dictionaries (insertText for nesting tabs if needed, List of request dictionaries (insertText for nesting tabs if needed,
@@ -484,14 +593,7 @@ def create_bullet_list_request(
for paragraph_start in paragraph_starts: for paragraph_start in paragraph_starts:
adjusted_start = paragraph_start + inserted_char_count adjusted_start = paragraph_start + inserted_char_count
requests.append( requests.append(create_insert_text_request(adjusted_start, tabs, doc_tab_id))
{
"insertText": {
"location": {"index": adjusted_start},
"text": tabs,
}
}
)
inserted_char_count += nesting_level inserted_char_count += nesting_level
# Keep createParagraphBullets range aligned to the same logical content. # Keep createParagraphBullets range aligned to the same logical content.
@@ -503,10 +605,14 @@ def create_bullet_list_request(
) )
# Create the bullet list # Create the bullet list
range_obj = {"startIndex": start_index, "endIndex": end_index}
if doc_tab_id:
range_obj["tabId"] = doc_tab_id
requests.append( requests.append(
{ {
"createParagraphBullets": { "createParagraphBullets": {
"range": {"startIndex": start_index, "endIndex": end_index}, "range": range_obj,
"bulletPreset": bullet_preset, "bulletPreset": bullet_preset,
} }
} }
@@ -539,6 +645,9 @@ def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]:
"insert_table": ["index", "rows", "columns"], "insert_table": ["index", "rows", "columns"],
"insert_page_break": ["index"], "insert_page_break": ["index"],
"find_replace": ["find_text", "replace_text"], "find_replace": ["find_text", "replace_text"],
"insert_doc_tab": ["title", "index"],
"delete_doc_tab": ["tab_id"],
"update_doc_tab": ["tab_id", "title"],
} }
if op_type not in required_fields: if op_type not in required_fields:

View File

@@ -8,7 +8,7 @@ import logging
import asyncio import asyncio
import io import io
import re import re
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
@@ -28,6 +28,9 @@ from gdocs.docs_helpers import (
create_insert_page_break_request, create_insert_page_break_request,
create_insert_image_request, create_insert_image_request,
create_bullet_list_request, create_bullet_list_request,
create_insert_doc_tab_request,
create_update_doc_tab_request,
create_delete_doc_tab_request
) )
# Import document structure and table utilities # Import document structure and table utilities
@@ -157,16 +160,18 @@ async def get_doc_content(
.execute .execute
) )
# Tab header format constant # Tab header format constant
TAB_HEADER_FORMAT = "\n--- TAB: {tab_name} ---\n" TAB_HEADER_FORMAT = "\n--- TAB: {tab_name} (ID: {tab_id}) ---\n"
def extract_text_from_elements(elements, tab_name=None, depth=0): def extract_text_from_elements(elements, tab_name=None, tab_id=None, depth=0):
"""Extract text from document elements (paragraphs, tables, etc.)""" """Extract text from document elements (paragraphs, tables, etc.)"""
# Prevent infinite recursion by limiting depth # Prevent infinite recursion by limiting depth
if depth > 5: if depth > 5:
return "" return ""
text_lines = [] text_lines = []
if tab_name: if tab_name:
text_lines.append(TAB_HEADER_FORMAT.format(tab_name=tab_name)) text_lines.append(
TAB_HEADER_FORMAT.format(tab_name=tab_name, tab_id=tab_id)
)
for element in elements: for element in elements:
if "paragraph" in element: if "paragraph" in element:
@@ -204,9 +209,9 @@ async def get_doc_content(
tab_id = props.get("tabId", "Unknown ID") tab_id = props.get("tabId", "Unknown ID")
# Add indentation for nested tabs to show hierarchy # Add indentation for nested tabs to show hierarchy
if level > 0: if level > 0:
tab_title = " " * level + f"{tab_title} ( ID: {tab_id})" tab_title = " " * level + f"{tab_title}"
tab_body = tab.get("documentTab", {}).get("body", {}).get("content", []) tab_body = tab.get("documentTab", {}).get("body", {}).get("content", [])
tab_text += extract_text_from_elements(tab_body, tab_title) tab_text += extract_text_from_elements(tab_body, tab_title, tab_id)
# Process child tabs (nested tabs) # Process child tabs (nested tabs)
child_tabs = tab.get("childTabs", []) child_tabs = tab.get("childTabs", [])
@@ -559,6 +564,7 @@ async def find_and_replace_doc(
find_text: str, find_text: str,
replace_text: str, replace_text: str,
match_case: bool = False, match_case: bool = False,
tab_id: Optional[str] = None,
) -> str: ) -> str:
""" """
Finds and replaces text throughout a Google Doc. Finds and replaces text throughout a Google Doc.
@@ -569,15 +575,16 @@ async def find_and_replace_doc(
find_text: Text to search for find_text: Text to search for
replace_text: Text to replace with replace_text: Text to replace with
match_case: Whether to match case exactly match_case: Whether to match case exactly
tab_id: Optional ID of the tab to target
Returns: Returns:
str: Confirmation message with replacement count str: Confirmation message with replacement count
""" """
logger.info( logger.info(
f"[find_and_replace_doc] Doc={document_id}, find='{find_text}', replace='{replace_text}'" f"[find_and_replace_doc] Doc={document_id}, find='{find_text}', replace='{replace_text}', tab='{tab_id}'"
) )
requests = [create_find_replace_request(find_text, replace_text, match_case)] requests = [create_find_replace_request(find_text, replace_text, match_case, tab_id)]
result = await asyncio.to_thread( result = await asyncio.to_thread(
service.documents() service.documents()
@@ -842,15 +849,41 @@ async def batch_update_doc(
Args: Args:
user_google_email: User's Google email address user_google_email: User's Google email address
document_id: ID of the document to update document_id: ID of the document to update
operations: List of operation dictionaries. Each operation should contain: operations: List of operation dicts. Each operation MUST have a 'type' field.
- type: Operation type ('insert_text', 'delete_text', 'replace_text', 'format_text', 'insert_table', 'insert_page_break') All operations accept an optional 'tab_id' to target a specific tab.
- Additional parameters specific to each operation type
Supported operation types and their parameters:
insert_text - required: index (int), text (str)
delete_text - required: start_index (int), end_index (int)
replace_text - required: start_index (int), end_index (int), text (str)
format_text - required: start_index (int), end_index (int)
optional: bold, italic, underline, font_size, font_family,
text_color, background_color, link_url
update_paragraph_style
- required: start_index (int), end_index (int)
optional: heading_level (0-6, 0=normal), alignment
(START/CENTER/END/JUSTIFIED), line_spacing,
indent_first_line, indent_start, indent_end,
space_above, space_below
insert_table - required: index (int), rows (int), columns (int)
insert_page_break- required: index (int)
find_replace - required: find_text (str), replace_text (str)
optional: match_case (bool, default false)
insert_doc_tab - required: title (str), index (int)
optional: parent_tab_id (str)
delete_doc_tab - required: tab_id (str)
update_doc_tab - required: tab_id (str), title (str)
Example operations: Example operations:
[ [
{"type": "insert_text", "index": 1, "text": "Hello World"}, {"type": "insert_text", "index": 1, "text": "Hello World"},
{"type": "format_text", "start_index": 1, "end_index": 12, "bold": true}, {"type": "format_text", "start_index": 1, "end_index": 12, "bold": true},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3} {"type": "update_paragraph_style", "start_index": 1, "end_index": 12,
"heading_level": 1, "alignment": "CENTER"},
{"type": "find_replace", "find_text": "foo", "replace_text": "bar"},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3},
{"type": "insert_doc_tab", "title": "Appendix", "index": 1}
] ]
Returns: Returns:
@@ -892,6 +925,7 @@ async def inspect_doc_structure(
user_google_email: str, user_google_email: str,
document_id: str, document_id: str,
detailed: bool = False, detailed: bool = False,
tab_id: str = None,
) -> str: ) -> str:
""" """
Essential tool for finding safe insertion points and understanding document structure. Essential tool for finding safe insertion points and understanding document structure.
@@ -901,6 +935,7 @@ async def inspect_doc_structure(
- Understanding document layout before making changes - Understanding document layout before making changes
- Locating existing tables and their positions - Locating existing tables and their positions
- Getting document statistics and complexity info - Getting document statistics and complexity info
- Inspecting structure of specific tabs
CRITICAL FOR TABLE OPERATIONS: CRITICAL FOR TABLE OPERATIONS:
ALWAYS call this BEFORE creating tables to get a safe insertion index. ALWAYS call this BEFORE creating tables to get a safe insertion index.
@@ -910,6 +945,7 @@ async def inspect_doc_structure(
- total_length: Maximum safe index for insertion - total_length: Maximum safe index for insertion
- tables: Number of existing tables - tables: Number of existing tables
- table_details: Position and dimensions of each table - table_details: Position and dimensions of each table
- tabs: List of available tabs in the document (if no tab_id specified)
WORKFLOW: WORKFLOW:
Step 1: Call this function Step 1: Call this function
@@ -921,20 +957,49 @@ async def inspect_doc_structure(
user_google_email: User's Google email address user_google_email: User's Google email address
document_id: ID of the document to inspect document_id: ID of the document to inspect
detailed: Whether to return detailed structure information detailed: Whether to return detailed structure information
tab_id: Optional ID of the tab to inspect. If not provided, inspects main document.
Returns: Returns:
str: JSON string containing document structure and safe insertion indices str: JSON string containing document structure and safe insertion indices
""" """
logger.debug(f"[inspect_doc_structure] Doc={document_id}, detailed={detailed}") logger.debug(
f"[inspect_doc_structure] Doc={document_id}, detailed={detailed}, tab_id={tab_id}"
)
# Get the document # Get the document
doc = await asyncio.to_thread( doc = await asyncio.to_thread(
service.documents().get(documentId=document_id).execute service.documents().get(documentId=document_id, includeTabsContent=True).execute
) )
# If tab_id is specified, find the tab and use its content
target_content = doc.get("body", {})
def find_tab(tabs, target_id):
for tab in tabs:
if tab.get("tabProperties", {}).get("tabId") == target_id:
return tab
if "childTabs" in tab:
found = find_tab(tab["childTabs"], target_id)
if found:
return found
return None
if tab_id:
tab = find_tab(doc.get("tabs", []), tab_id)
if tab and "documentTab" in tab:
target_content = tab["documentTab"].get("body", {})
elif tab:
return f"Error: Tab {tab_id} is not a document tab and has no body content."
else:
return f"Error: Tab {tab_id} not found in document."
# Create a dummy doc object for analysis tools that expect a full doc
analysis_doc = doc.copy()
analysis_doc["body"] = target_content
if detailed: if detailed:
# Return full parsed structure # Return full parsed structure
structure = parse_document_structure(doc) structure = parse_document_structure(analysis_doc)
# Simplify for JSON serialization # Simplify for JSON serialization
result = { result = {
@@ -991,10 +1056,10 @@ async def inspect_doc_structure(
else: else:
# Return basic analysis # Return basic analysis
result = analyze_document_complexity(doc) result = analyze_document_complexity(analysis_doc)
# Add table information # Add table information
tables = find_tables(doc) tables = find_tables(analysis_doc)
if tables: if tables:
result["table_details"] = [] result["table_details"] = []
for i, table in enumerate(tables): for i, table in enumerate(tables):
@@ -1008,6 +1073,26 @@ async def inspect_doc_structure(
} }
) )
# Always include available tabs if no tab_id was specified
if not tab_id:
def get_tabs_summary(tabs):
summary = []
for tab in tabs:
props = tab.get("tabProperties", {})
tab_info = {
"title": props.get("title"),
"tab_id": props.get("tabId"),
}
if "childTabs" in tab:
tab_info["child_tabs"] = get_tabs_summary(tab["childTabs"])
summary.append(tab_info)
return summary
result["tabs"] = get_tabs_summary(doc.get("tabs", []))
if tab_id:
result["inspected_tab_id"] = tab_id
link = f"https://docs.google.com/document/d/{document_id}/edit" link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Document structure analysis for {document_id}:\n\n{json.dumps(result, indent=2)}\n\nLink: {link}" return f"Document structure analysis for {document_id}:\n\n{json.dumps(result, indent=2)}\n\nLink: {link}"
@@ -1674,6 +1759,114 @@ async def get_doc_as_markdown(
return markdown.rstrip("\n") + "\n\n" + appendix return markdown.rstrip("\n") + "\n\n" + appendix
@server.tool()
@handle_http_errors("insert_doc_tab", service_type="docs")
@require_google_service("docs", "docs_write")
async def insert_doc_tab(
service: Any,
user_google_email: str,
document_id: str,
title: str,
index: int,
parent_tab_id: Optional[str] = None,
) -> str:
"""
Inserts a new tab into a Google Doc.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
title: Title of the new tab
index: Position index for the new tab (0-based among sibling tabs)
parent_tab_id: Optional ID of a parent tab to nest the new tab under
Returns:
str: Confirmation message with document link
"""
logger.info(f"[insert_doc_tab] Doc={document_id}, title='{title}', index={index}")
request = create_insert_doc_tab_request(title, index, parent_tab_id)
await asyncio.to_thread(
service.documents()
.batchUpdate(documentId=document_id, body={"requests": [request]})
.execute
)
link = f"https://docs.google.com/document/d/{document_id}/edit"
msg = f"Inserted tab '{title}' at index {index} in document {document_id}."
if parent_tab_id:
msg += f" Nested under parent tab {parent_tab_id}."
return f"{msg} Link: {link}"
@server.tool()
@handle_http_errors("delete_doc_tab", service_type="docs")
@require_google_service("docs", "docs_write")
async def delete_doc_tab(
service: Any,
user_google_email: str,
document_id: str,
tab_id: str,
) -> str:
"""
Deletes a tab from a Google Doc by its tab ID.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
tab_id: ID of the tab to delete (use inspect_doc_structure to find tab IDs)
Returns:
str: Confirmation message with document link
"""
logger.info(f"[delete_doc_tab] Doc={document_id}, tab_id='{tab_id}'")
request = create_delete_doc_tab_request(tab_id)
await asyncio.to_thread(
service.documents()
.batchUpdate(documentId=document_id, body={"requests": [request]})
.execute
)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Deleted tab '{tab_id}' from document {document_id}. Link: {link}"
@server.tool()
@handle_http_errors("update_doc_tab", service_type="docs")
@require_google_service("docs", "docs_write")
async def update_doc_tab(
service: Any,
user_google_email: str,
document_id: str,
tab_id: str,
title: str,
) -> str:
"""
Renames a tab in a Google Doc.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
tab_id: ID of the tab to rename (use inspect_doc_structure to find tab IDs)
title: New title for the tab
Returns:
str: Confirmation message with document link
"""
logger.info(f"[update_doc_tab] Doc={document_id}, tab_id='{tab_id}', title='{title}'")
request = create_update_doc_tab_request(tab_id, title)
await asyncio.to_thread(
service.documents()
.batchUpdate(documentId=document_id, body={"requests": [request]})
.execute
)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Renamed tab '{tab_id}' to '{title}' in document {document_id}. Link: {link}"
# Create comment management tools for documents # Create comment management tools for documents
_comment_tools = create_comment_tools("document", "document_id") _comment_tools = create_comment_tools("document", "document_id")

View File

@@ -17,6 +17,9 @@ from gdocs.docs_helpers import (
create_find_replace_request, create_find_replace_request,
create_insert_table_request, create_insert_table_request,
create_insert_page_break_request, create_insert_page_break_request,
create_insert_doc_tab_request,
create_delete_doc_tab_request,
create_update_doc_tab_request,
validate_operation, validate_operation,
) )
@@ -161,20 +164,26 @@ class BatchOperationManager:
Returns: Returns:
Tuple of (request, description) Tuple of (request, description)
""" """
tab_id = op.get("tab_id")
if op_type == "insert_text": if op_type == "insert_text":
request = create_insert_text_request(op["index"], op["text"]) request = create_insert_text_request(op["index"], op["text"], tab_id)
description = f"insert text at {op['index']}" description = f"insert text at {op['index']}"
elif op_type == "delete_text": elif op_type == "delete_text":
request = create_delete_range_request(op["start_index"], op["end_index"]) request = create_delete_range_request(
op["start_index"], op["end_index"], tab_id
)
description = f"delete text {op['start_index']}-{op['end_index']}" description = f"delete text {op['start_index']}-{op['end_index']}"
elif op_type == "replace_text": elif op_type == "replace_text":
# Replace is delete + insert (must be done in this order) # Replace is delete + insert (must be done in this order)
delete_request = create_delete_range_request( delete_request = create_delete_range_request(
op["start_index"], op["end_index"] op["start_index"], op["end_index"], tab_id
)
insert_request = create_insert_text_request(
op["start_index"], op["text"], tab_id
) )
insert_request = create_insert_text_request(op["start_index"], op["text"])
# Return both requests as a list # Return both requests as a list
request = [delete_request, insert_request] request = [delete_request, insert_request]
description = f"replace text {op['start_index']}-{op['end_index']} with '{op['text'][:20]}{'...' if len(op['text']) > 20 else ''}'" description = f"replace text {op['start_index']}-{op['end_index']} with '{op['text'][:20]}{'...' if len(op['text']) > 20 else ''}'"
@@ -191,6 +200,7 @@ class BatchOperationManager:
op.get("text_color"), op.get("text_color"),
op.get("background_color"), op.get("background_color"),
op.get("link_url"), op.get("link_url"),
tab_id,
) )
if not request: if not request:
@@ -226,6 +236,7 @@ class BatchOperationManager:
op.get("indent_end"), op.get("indent_end"),
op.get("space_above"), op.get("space_above"),
op.get("space_below"), op.get("space_below"),
tab_id,
) )
if not request: if not request:
@@ -269,20 +280,34 @@ class BatchOperationManager:
elif op_type == "insert_table": elif op_type == "insert_table":
request = create_insert_table_request( request = create_insert_table_request(
op["index"], op["rows"], op["columns"] op["index"], op["rows"], op["columns"], tab_id
) )
description = f"insert {op['rows']}x{op['columns']} table at {op['index']}" description = f"insert {op['rows']}x{op['columns']} table at {op['index']}"
elif op_type == "insert_page_break": elif op_type == "insert_page_break":
request = create_insert_page_break_request(op["index"]) request = create_insert_page_break_request(op["index"], tab_id)
description = f"insert page break at {op['index']}" description = f"insert page break at {op['index']}"
elif op_type == "find_replace": elif op_type == "find_replace":
request = create_find_replace_request( request = create_find_replace_request(
op["find_text"], op["replace_text"], op.get("match_case", False) op["find_text"], op["replace_text"], op.get("match_case", False), tab_id
) )
description = f"find/replace '{op['find_text']}''{op['replace_text']}'" description = f"find/replace '{op['find_text']}''{op['replace_text']}'"
elif op_type == "insert_doc_tab":
request = create_insert_doc_tab_request(op["title"], op["index"], op.get("parent_tab_id"))
description = f"insert tab '{op['title']}' at {op['index']}"
if op.get("parent_tab_id"):
description += f" under parent tab {op['parent_tab_id']}"
elif op_type == "delete_doc_tab":
request = create_delete_doc_tab_request(op["tab_id"])
description = f"delete tab '{op['tab_id']}'"
elif op_type == "update_doc_tab":
request = create_update_doc_tab_request(op["tab_id"], op["title"])
description = f"rename tab '{op['tab_id']}' to '{op['title']}'"
else: else:
supported_types = [ supported_types = [
"insert_text", "insert_text",
@@ -403,6 +428,18 @@ class BatchOperationManager:
"optional": ["match_case"], "optional": ["match_case"],
"description": "Find and replace text throughout document", "description": "Find and replace text throughout document",
}, },
"insert_doc_tab": {
"required": ["title", "index"],
"description": "Insert a new document tab with given title at specified index",
},
"delete_doc_tab": {
"required": ["tab_id"],
"description": "Delete a document tab by its ID",
},
"update_doc_tab": {
"required": ["tab_id", "title"],
"description": "Rename a document tab",
},
}, },
"example_operations": [ "example_operations": [
{"type": "insert_text", "index": 1, "text": "Hello World"}, {"type": "insert_text", "index": 1, "text": "Hello World"},