Skip to content

Commit c719b25

Browse files
Merge pull request #159 from taylorwilsdon/enhanced_docs
feat: Enhanced Google Doc Editing Granularity
2 parents 68f2b41 + a3db9ce commit c719b25

File tree

12 files changed

+4383
-1123
lines changed

12 files changed

+4383
-1123
lines changed

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
__pycache__/
33
*.py[cod]
44
*.so
5+
.mcp.json
6+
claude.md
57

68
# ---- Packaging ---------------------------------------------------------
79
*.egg-info/
@@ -22,4 +24,8 @@ venv/
2224
client_secret.json
2325

2426
# ---- Logs --------------------------------------------------------------
25-
mcp_server_debug.log
27+
mcp_server_debug.log
28+
29+
# ---- Local development files -------------------------------------------
30+
/.credentials
31+
/.claude

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ A production-ready MCP server that integrates all major Google Workspace service
5959
- **📅 Google Calendar**: Full calendar management with event CRUD operations
6060
- **📁 Google Drive**: File operations with native Microsoft Office format support (.docx, .xlsx)
6161
- **📧 Gmail**: Complete email management with search, send, and draft capabilities
62-
- **📄 Google Docs**: Document operations including content extraction, creation, and comment management
62+
- **📄 Google Docs**: Complete document management including content extraction, creation, full editing capabilities, and comment management
6363
- **📊 Google Sheets**: Comprehensive spreadsheet management with flexible cell operations and comment management
6464
- **🖼️ Google Slides**: Presentation management with slide creation, updates, content manipulation, and comment management
6565
- **📝 Google Forms**: Form creation, retrieval, publish settings, and response management
@@ -499,6 +499,13 @@ When calling a tool:
499499
| `get_doc_content` | Extract document text |
500500
| `list_docs_in_folder` | List docs in folder |
501501
| `create_doc` | Create new documents |
502+
| `update_doc_text` | Insert or replace text at specific positions |
503+
| `find_and_replace_doc` | Find and replace text throughout document |
504+
| `format_doc_text` | Apply text formatting (bold, italic, underline, fonts) |
505+
| `insert_doc_elements` | Add tables, lists, or page breaks |
506+
| `insert_doc_image` | Insert images from Drive or URLs |
507+
| `update_doc_headers_footers` | Modify document headers and footers |
508+
| `batch_update_doc` | Execute multiple document operations atomically |
502509
| `read_doc_comments` | Read all comments and replies |
503510
| `create_doc_comment` | Create new comments |
504511
| `reply_to_comment` | Reply to existing comments |

gdocs/docs_helpers.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""
2+
Google Docs Helper Functions
3+
4+
This module provides utility functions for common Google Docs operations
5+
to simplify the implementation of document editing tools.
6+
"""
7+
import logging
8+
from typing import Dict, Any, Optional, Tuple
9+
10+
logger = logging.getLogger(__name__)
11+
12+
def build_text_style(
13+
bold: bool = None,
14+
italic: bool = None,
15+
underline: bool = None,
16+
font_size: int = None,
17+
font_family: str = None
18+
) -> tuple[Dict[str, Any], list[str]]:
19+
"""
20+
Build text style object for Google Docs API requests.
21+
22+
Args:
23+
bold: Whether text should be bold
24+
italic: Whether text should be italic
25+
underline: Whether text should be underlined
26+
font_size: Font size in points
27+
font_family: Font family name
28+
29+
Returns:
30+
Tuple of (text_style_dict, list_of_field_names)
31+
"""
32+
text_style = {}
33+
fields = []
34+
35+
if bold is not None:
36+
text_style['bold'] = bold
37+
fields.append('bold')
38+
39+
if italic is not None:
40+
text_style['italic'] = italic
41+
fields.append('italic')
42+
43+
if underline is not None:
44+
text_style['underline'] = underline
45+
fields.append('underline')
46+
47+
if font_size is not None:
48+
text_style['fontSize'] = {'magnitude': font_size, 'unit': 'PT'}
49+
fields.append('fontSize')
50+
51+
if font_family is not None:
52+
text_style['weightedFontFamily'] = {'fontFamily': font_family}
53+
fields.append('weightedFontFamily')
54+
55+
return text_style, fields
56+
57+
def create_insert_text_request(index: int, text: str) -> Dict[str, Any]:
58+
"""
59+
Create an insertText request for Google Docs API.
60+
61+
Args:
62+
index: Position to insert text
63+
text: Text to insert
64+
65+
Returns:
66+
Dictionary representing the insertText request
67+
"""
68+
return {
69+
'insertText': {
70+
'location': {'index': index},
71+
'text': text
72+
}
73+
}
74+
75+
def create_delete_range_request(start_index: int, end_index: int) -> Dict[str, Any]:
76+
"""
77+
Create a deleteContentRange request for Google Docs API.
78+
79+
Args:
80+
start_index: Start position of content to delete
81+
end_index: End position of content to delete
82+
83+
Returns:
84+
Dictionary representing the deleteContentRange request
85+
"""
86+
return {
87+
'deleteContentRange': {
88+
'range': {
89+
'startIndex': start_index,
90+
'endIndex': end_index
91+
}
92+
}
93+
}
94+
95+
def create_format_text_request(
96+
start_index: int,
97+
end_index: int,
98+
bold: bool = None,
99+
italic: bool = None,
100+
underline: bool = None,
101+
font_size: int = None,
102+
font_family: str = None
103+
) -> Optional[Dict[str, Any]]:
104+
"""
105+
Create an updateTextStyle request for Google Docs API.
106+
107+
Args:
108+
start_index: Start position of text to format
109+
end_index: End position of text to format
110+
bold: Whether text should be bold
111+
italic: Whether text should be italic
112+
underline: Whether text should be underlined
113+
font_size: Font size in points
114+
font_family: Font family name
115+
116+
Returns:
117+
Dictionary representing the updateTextStyle request, or None if no styles provided
118+
"""
119+
text_style, fields = build_text_style(bold, italic, underline, font_size, font_family)
120+
121+
if not text_style:
122+
return None
123+
124+
return {
125+
'updateTextStyle': {
126+
'range': {
127+
'startIndex': start_index,
128+
'endIndex': end_index
129+
},
130+
'textStyle': text_style,
131+
'fields': ','.join(fields)
132+
}
133+
}
134+
135+
def create_find_replace_request(
136+
find_text: str,
137+
replace_text: str,
138+
match_case: bool = False
139+
) -> Dict[str, Any]:
140+
"""
141+
Create a replaceAllText request for Google Docs API.
142+
143+
Args:
144+
find_text: Text to find
145+
replace_text: Text to replace with
146+
match_case: Whether to match case exactly
147+
148+
Returns:
149+
Dictionary representing the replaceAllText request
150+
"""
151+
return {
152+
'replaceAllText': {
153+
'containsText': {
154+
'text': find_text,
155+
'matchCase': match_case
156+
},
157+
'replaceText': replace_text
158+
}
159+
}
160+
161+
def create_insert_table_request(index: int, rows: int, columns: int) -> Dict[str, Any]:
162+
"""
163+
Create an insertTable request for Google Docs API.
164+
165+
Args:
166+
index: Position to insert table
167+
rows: Number of rows
168+
columns: Number of columns
169+
170+
Returns:
171+
Dictionary representing the insertTable request
172+
"""
173+
return {
174+
'insertTable': {
175+
'location': {'index': index},
176+
'rows': rows,
177+
'columns': columns
178+
}
179+
}
180+
181+
def create_insert_page_break_request(index: int) -> Dict[str, Any]:
182+
"""
183+
Create an insertPageBreak request for Google Docs API.
184+
185+
Args:
186+
index: Position to insert page break
187+
188+
Returns:
189+
Dictionary representing the insertPageBreak request
190+
"""
191+
return {
192+
'insertPageBreak': {
193+
'location': {'index': index}
194+
}
195+
}
196+
197+
def create_insert_image_request(
198+
index: int,
199+
image_uri: str,
200+
width: int = None,
201+
height: int = None
202+
) -> Dict[str, Any]:
203+
"""
204+
Create an insertInlineImage request for Google Docs API.
205+
206+
Args:
207+
index: Position to insert image
208+
image_uri: URI of the image (Drive URL or public URL)
209+
width: Image width in points
210+
height: Image height in points
211+
212+
Returns:
213+
Dictionary representing the insertInlineImage request
214+
"""
215+
request = {
216+
'insertInlineImage': {
217+
'location': {'index': index},
218+
'uri': image_uri
219+
}
220+
}
221+
222+
# Add size properties if specified
223+
object_size = {}
224+
if width is not None:
225+
object_size['width'] = {'magnitude': width, 'unit': 'PT'}
226+
if height is not None:
227+
object_size['height'] = {'magnitude': height, 'unit': 'PT'}
228+
229+
if object_size:
230+
request['insertInlineImage']['objectSize'] = object_size
231+
232+
return request
233+
234+
def create_bullet_list_request(
235+
start_index: int,
236+
end_index: int,
237+
list_type: str = "UNORDERED"
238+
) -> Dict[str, Any]:
239+
"""
240+
Create a createParagraphBullets request for Google Docs API.
241+
242+
Args:
243+
start_index: Start of text range to convert to list
244+
end_index: End of text range to convert to list
245+
list_type: Type of list ("UNORDERED" or "ORDERED")
246+
247+
Returns:
248+
Dictionary representing the createParagraphBullets request
249+
"""
250+
bullet_preset = (
251+
'BULLET_DISC_CIRCLE_SQUARE'
252+
if list_type == "UNORDERED"
253+
else 'NUMBERED_DECIMAL_ALPHA_ROMAN'
254+
)
255+
256+
return {
257+
'createParagraphBullets': {
258+
'range': {
259+
'startIndex': start_index,
260+
'endIndex': end_index
261+
},
262+
'bulletPreset': bullet_preset
263+
}
264+
}
265+
266+
def validate_operation(operation: Dict[str, Any]) -> Tuple[bool, str]:
267+
"""
268+
Validate a batch operation dictionary.
269+
270+
Args:
271+
operation: Operation dictionary to validate
272+
273+
Returns:
274+
Tuple of (is_valid, error_message)
275+
"""
276+
op_type = operation.get('type')
277+
if not op_type:
278+
return False, "Missing 'type' field"
279+
280+
# Validate required fields for each operation type
281+
required_fields = {
282+
'insert_text': ['index', 'text'],
283+
'delete_text': ['start_index', 'end_index'],
284+
'replace_text': ['start_index', 'end_index', 'text'],
285+
'format_text': ['start_index', 'end_index'],
286+
'insert_table': ['index', 'rows', 'columns'],
287+
'insert_page_break': ['index'],
288+
'find_replace': ['find_text', 'replace_text']
289+
}
290+
291+
if op_type not in required_fields:
292+
return False, f"Unsupported operation type: {op_type or 'None'}"
293+
294+
for field in required_fields[op_type]:
295+
if field not in operation:
296+
return False, f"Missing required field: {field}"
297+
298+
return True, ""
299+

0 commit comments

Comments
 (0)