google-workspace-mcp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- google_workspace_mcp/__init__.py +3 -0
- google_workspace_mcp/__main__.py +43 -0
- google_workspace_mcp/app.py +8 -0
- google_workspace_mcp/auth/__init__.py +7 -0
- google_workspace_mcp/auth/gauth.py +62 -0
- google_workspace_mcp/config.py +60 -0
- google_workspace_mcp/prompts/__init__.py +3 -0
- google_workspace_mcp/prompts/calendar.py +36 -0
- google_workspace_mcp/prompts/drive.py +18 -0
- google_workspace_mcp/prompts/gmail.py +65 -0
- google_workspace_mcp/prompts/slides.py +40 -0
- google_workspace_mcp/resources/__init__.py +13 -0
- google_workspace_mcp/resources/calendar.py +79 -0
- google_workspace_mcp/resources/drive.py +93 -0
- google_workspace_mcp/resources/gmail.py +58 -0
- google_workspace_mcp/resources/sheets_resources.py +92 -0
- google_workspace_mcp/resources/slides.py +421 -0
- google_workspace_mcp/services/__init__.py +21 -0
- google_workspace_mcp/services/base.py +73 -0
- google_workspace_mcp/services/calendar.py +256 -0
- google_workspace_mcp/services/docs_service.py +388 -0
- google_workspace_mcp/services/drive.py +454 -0
- google_workspace_mcp/services/gmail.py +676 -0
- google_workspace_mcp/services/sheets_service.py +466 -0
- google_workspace_mcp/services/slides.py +959 -0
- google_workspace_mcp/tools/__init__.py +7 -0
- google_workspace_mcp/tools/calendar.py +229 -0
- google_workspace_mcp/tools/docs_tools.py +277 -0
- google_workspace_mcp/tools/drive.py +221 -0
- google_workspace_mcp/tools/gmail.py +344 -0
- google_workspace_mcp/tools/sheets_tools.py +322 -0
- google_workspace_mcp/tools/slides.py +478 -0
- google_workspace_mcp/utils/__init__.py +1 -0
- google_workspace_mcp/utils/markdown_slides.py +504 -0
- google_workspace_mcp-1.0.0.dist-info/METADATA +547 -0
- google_workspace_mcp-1.0.0.dist-info/RECORD +38 -0
- google_workspace_mcp-1.0.0.dist-info/WHEEL +4 -0
- google_workspace_mcp-1.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,256 @@
|
|
1
|
+
"""
|
2
|
+
Google Calendar service implementation.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import logging
|
6
|
+
from datetime import datetime
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
import pytz
|
10
|
+
|
11
|
+
from google_workspace_mcp.services.base import BaseGoogleService
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
class CalendarService(BaseGoogleService):
|
17
|
+
"""
|
18
|
+
Service for interacting with Google Calendar API.
|
19
|
+
"""
|
20
|
+
|
21
|
+
def __init__(self):
|
22
|
+
"""Initialize the Calendar service."""
|
23
|
+
super().__init__("calendar", "v3")
|
24
|
+
|
25
|
+
def list_calendars(self) -> list[dict[str, Any]]:
|
26
|
+
"""
|
27
|
+
Lists all calendars accessible by the user.
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
List of calendar objects with their metadata
|
31
|
+
"""
|
32
|
+
try:
|
33
|
+
calendar_list = self.service.calendarList().list().execute()
|
34
|
+
|
35
|
+
calendars = []
|
36
|
+
|
37
|
+
for calendar in calendar_list.get("items", []):
|
38
|
+
if calendar.get("kind") == "calendar#calendarListEntry":
|
39
|
+
calendars.append(
|
40
|
+
{
|
41
|
+
"id": calendar.get("id"),
|
42
|
+
"summary": calendar.get("summary"),
|
43
|
+
"primary": calendar.get("primary", False),
|
44
|
+
"time_zone": calendar.get("timeZone"),
|
45
|
+
"etag": calendar.get("etag"),
|
46
|
+
"access_role": calendar.get("accessRole"),
|
47
|
+
}
|
48
|
+
)
|
49
|
+
|
50
|
+
return calendars
|
51
|
+
|
52
|
+
except Exception as e:
|
53
|
+
return self.handle_api_error("list_calendars", e)
|
54
|
+
|
55
|
+
def get_events(
|
56
|
+
self,
|
57
|
+
calendar_id: str = "primary",
|
58
|
+
time_min: str | None = None,
|
59
|
+
time_max: str | None = None,
|
60
|
+
max_results: int = 250,
|
61
|
+
show_deleted: bool = False,
|
62
|
+
) -> list[dict[str, Any]]:
|
63
|
+
"""
|
64
|
+
Retrieve calendar events within a specified time range.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
calendar_id: ID of the calendar to query
|
68
|
+
time_min: Start time in RFC3339 format. Defaults to current time.
|
69
|
+
time_max: End time in RFC3339 format
|
70
|
+
max_results: Maximum number of events to return
|
71
|
+
show_deleted: Whether to include deleted events
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
List of calendar events
|
75
|
+
"""
|
76
|
+
try:
|
77
|
+
# If no time_min specified, use current time
|
78
|
+
if not time_min:
|
79
|
+
time_min = datetime.now(pytz.UTC).isoformat()
|
80
|
+
|
81
|
+
# Ensure max_results is within limits
|
82
|
+
max_results = min(max(1, max_results), 2500)
|
83
|
+
|
84
|
+
# Prepare parameters
|
85
|
+
params = {
|
86
|
+
"calendarId": calendar_id,
|
87
|
+
"timeMin": time_min,
|
88
|
+
"maxResults": max_results,
|
89
|
+
"singleEvents": True,
|
90
|
+
"orderBy": "startTime",
|
91
|
+
"showDeleted": show_deleted,
|
92
|
+
}
|
93
|
+
|
94
|
+
# Add optional time_max if specified
|
95
|
+
if time_max:
|
96
|
+
params["timeMax"] = time_max
|
97
|
+
|
98
|
+
# Execute the events().list() method
|
99
|
+
events_result = self.service.events().list(**params).execute()
|
100
|
+
|
101
|
+
# Extract the events
|
102
|
+
events = events_result.get("items", [])
|
103
|
+
|
104
|
+
# Process and return the events
|
105
|
+
processed_events = []
|
106
|
+
for event in events:
|
107
|
+
processed_event = {
|
108
|
+
"id": event.get("id"),
|
109
|
+
"summary": event.get("summary"),
|
110
|
+
"description": event.get("description"),
|
111
|
+
"start": event.get("start"),
|
112
|
+
"end": event.get("end"),
|
113
|
+
"status": event.get("status"),
|
114
|
+
"creator": event.get("creator"),
|
115
|
+
"organizer": event.get("organizer"),
|
116
|
+
"attendees": event.get("attendees"),
|
117
|
+
"location": event.get("location"),
|
118
|
+
"hangoutLink": event.get("hangoutLink"),
|
119
|
+
"conferenceData": event.get("conferenceData"),
|
120
|
+
"recurringEventId": event.get("recurringEventId"),
|
121
|
+
}
|
122
|
+
processed_events.append(processed_event)
|
123
|
+
|
124
|
+
return processed_events
|
125
|
+
|
126
|
+
except Exception as e:
|
127
|
+
return self.handle_api_error("get_events", e)
|
128
|
+
|
129
|
+
def create_event(
|
130
|
+
self,
|
131
|
+
summary: str,
|
132
|
+
start_time: str,
|
133
|
+
end_time: str,
|
134
|
+
location: str | None = None,
|
135
|
+
description: str | None = None,
|
136
|
+
attendees: list[str] | None = None,
|
137
|
+
send_notifications: bool = True,
|
138
|
+
timezone: str | None = None,
|
139
|
+
calendar_id: str = "primary",
|
140
|
+
) -> dict[str, Any] | None:
|
141
|
+
"""
|
142
|
+
Create a new calendar event.
|
143
|
+
|
144
|
+
Args:
|
145
|
+
summary: Title of the event
|
146
|
+
start_time: Start time in RFC3339 format
|
147
|
+
end_time: End time in RFC3339 format
|
148
|
+
location: Location of the event
|
149
|
+
description: Description of the event
|
150
|
+
attendees: List of attendee email addresses
|
151
|
+
send_notifications: Whether to send notifications to attendees
|
152
|
+
timezone: Timezone for the event (e.g. 'America/New_York')
|
153
|
+
calendar_id: ID of the calendar to create the event in
|
154
|
+
|
155
|
+
Returns:
|
156
|
+
Created event data or None if creation fails
|
157
|
+
"""
|
158
|
+
try:
|
159
|
+
# Prepare event data
|
160
|
+
event = {
|
161
|
+
"summary": summary,
|
162
|
+
"start": {
|
163
|
+
"dateTime": start_time,
|
164
|
+
"timeZone": timezone or "UTC",
|
165
|
+
},
|
166
|
+
"end": {
|
167
|
+
"dateTime": end_time,
|
168
|
+
"timeZone": timezone or "UTC",
|
169
|
+
},
|
170
|
+
}
|
171
|
+
|
172
|
+
# Add optional fields if provided
|
173
|
+
if location:
|
174
|
+
event["location"] = location
|
175
|
+
if description:
|
176
|
+
event["description"] = description
|
177
|
+
if attendees:
|
178
|
+
event["attendees"] = [{"email": email} for email in attendees]
|
179
|
+
|
180
|
+
# Map boolean to required string for sendUpdates
|
181
|
+
send_updates_value = "all" if send_notifications else "none"
|
182
|
+
|
183
|
+
# Create the event
|
184
|
+
return (
|
185
|
+
self.service.events()
|
186
|
+
.insert(
|
187
|
+
calendarId=calendar_id,
|
188
|
+
body=event,
|
189
|
+
sendUpdates=send_updates_value,
|
190
|
+
)
|
191
|
+
.execute()
|
192
|
+
)
|
193
|
+
|
194
|
+
except Exception as e:
|
195
|
+
return self.handle_api_error("create_event", e)
|
196
|
+
|
197
|
+
def delete_event(
|
198
|
+
self,
|
199
|
+
event_id: str,
|
200
|
+
send_notifications: bool = True,
|
201
|
+
calendar_id: str = "primary",
|
202
|
+
) -> bool:
|
203
|
+
"""
|
204
|
+
Delete a calendar event by its ID.
|
205
|
+
|
206
|
+
Args:
|
207
|
+
event_id: The ID of the event to delete
|
208
|
+
send_notifications: Whether to send cancellation notifications to attendees
|
209
|
+
calendar_id: ID of the calendar containing the event
|
210
|
+
|
211
|
+
Returns:
|
212
|
+
True if deletion was successful, False otherwise
|
213
|
+
"""
|
214
|
+
try:
|
215
|
+
# Map boolean to required string for sendUpdates
|
216
|
+
send_updates_value = "all" if send_notifications else "none"
|
217
|
+
|
218
|
+
self.service.events().delete(
|
219
|
+
calendarId=calendar_id,
|
220
|
+
eventId=event_id,
|
221
|
+
sendUpdates=send_updates_value,
|
222
|
+
).execute()
|
223
|
+
return True
|
224
|
+
|
225
|
+
except Exception as e:
|
226
|
+
self.handle_api_error("delete_event", e)
|
227
|
+
return False
|
228
|
+
|
229
|
+
def get_event_details(
|
230
|
+
self, event_id: str, calendar_id: str = "primary"
|
231
|
+
) -> dict[str, Any] | None:
|
232
|
+
"""
|
233
|
+
Retrieves details for a specific event.
|
234
|
+
|
235
|
+
Args:
|
236
|
+
event_id: The ID of the event.
|
237
|
+
calendar_id: The ID of the calendar the event belongs to. Defaults to "primary".
|
238
|
+
|
239
|
+
Returns:
|
240
|
+
A dictionary containing the event details or an error dictionary.
|
241
|
+
"""
|
242
|
+
try:
|
243
|
+
logger.info(
|
244
|
+
f"Fetching details for event ID: {event_id} from calendar: {calendar_id}"
|
245
|
+
)
|
246
|
+
event = (
|
247
|
+
self.service.events()
|
248
|
+
.get(calendarId=calendar_id, eventId=event_id)
|
249
|
+
.execute()
|
250
|
+
)
|
251
|
+
logger.info(
|
252
|
+
f"Successfully fetched details for event: {event.get('summary')}"
|
253
|
+
)
|
254
|
+
return event # Return the full event resource as per API
|
255
|
+
except Exception as e:
|
256
|
+
return self.handle_api_error("get_event_details", e)
|
@@ -0,0 +1,388 @@
|
|
1
|
+
"""
|
2
|
+
Google Docs service implementation.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import io
|
6
|
+
import logging
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
from googleapiclient.discovery import build
|
10
|
+
from googleapiclient.errors import HttpError
|
11
|
+
from googleapiclient.http import MediaIoBaseDownload
|
12
|
+
|
13
|
+
from google_workspace_mcp.auth import gauth
|
14
|
+
from google_workspace_mcp.services.base import BaseGoogleService
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
class DocsService(BaseGoogleService):
|
20
|
+
"""
|
21
|
+
Service for interacting with the Google Docs API.
|
22
|
+
"""
|
23
|
+
|
24
|
+
def __init__(self):
|
25
|
+
"""Initialize the Google Docs service."""
|
26
|
+
super().__init__("docs", "v1")
|
27
|
+
# Note: The Google Docs API client is built using 'docs', 'v1'.
|
28
|
+
# The actual API calls will be like self.service.documents().<method>
|
29
|
+
|
30
|
+
def create_document(self, title: str) -> dict[str, Any] | None:
|
31
|
+
"""
|
32
|
+
Creates a new Google Document with the specified title.
|
33
|
+
|
34
|
+
Args:
|
35
|
+
title: The title for the new document.
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
A dictionary containing the created document's ID and title, or an error dictionary.
|
39
|
+
"""
|
40
|
+
try:
|
41
|
+
logger.info(f"Creating new Google Document with title: '{title}'")
|
42
|
+
body = {"title": title}
|
43
|
+
document = self.service.documents().create(body=body).execute()
|
44
|
+
# The response includes documentId, title, and other fields.
|
45
|
+
logger.info(f"Successfully created document: {document.get('title')} (ID: {document.get('documentId')})")
|
46
|
+
return {
|
47
|
+
"document_id": document.get("documentId"),
|
48
|
+
"title": document.get("title"),
|
49
|
+
"document_link": f"https://docs.google.com/document/d/{document.get('documentId')}/edit",
|
50
|
+
}
|
51
|
+
except HttpError as error:
|
52
|
+
logger.error(f"Error creating document '{title}': {error}")
|
53
|
+
return self.handle_api_error("create_document", error)
|
54
|
+
except Exception as e:
|
55
|
+
logger.exception(f"Unexpected error creating document '{title}'")
|
56
|
+
return {
|
57
|
+
"error": True,
|
58
|
+
"error_type": "unexpected_service_error",
|
59
|
+
"message": str(e),
|
60
|
+
"operation": "create_document",
|
61
|
+
}
|
62
|
+
|
63
|
+
def get_document_metadata(self, document_id: str) -> dict[str, Any] | None:
|
64
|
+
"""
|
65
|
+
Retrieves metadata for a specific Google Document.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
document_id: The ID of the Google Document.
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
A dictionary containing document metadata (ID, title), or an error dictionary.
|
72
|
+
"""
|
73
|
+
try:
|
74
|
+
logger.info(f"Fetching metadata for document ID: {document_id}")
|
75
|
+
# The 'fields' parameter can be used to specify which fields to return.
|
76
|
+
# e.g., "documentId,title,body,revisionId,suggestionsViewMode"
|
77
|
+
document = self.service.documents().get(documentId=document_id, fields="documentId,title").execute()
|
78
|
+
logger.info(
|
79
|
+
f"Successfully fetched metadata for document: {document.get('title')} (ID: {document.get('documentId')})"
|
80
|
+
)
|
81
|
+
return {
|
82
|
+
"document_id": document.get("documentId"),
|
83
|
+
"title": document.get("title"),
|
84
|
+
"document_link": f"https://docs.google.com/document/d/{document.get('documentId')}/edit",
|
85
|
+
}
|
86
|
+
except HttpError as error:
|
87
|
+
logger.error(f"Error fetching document metadata for ID {document_id}: {error}")
|
88
|
+
return self.handle_api_error("get_document_metadata", error)
|
89
|
+
except Exception as e:
|
90
|
+
logger.exception(f"Unexpected error fetching document metadata for ID {document_id}")
|
91
|
+
return {
|
92
|
+
"error": True,
|
93
|
+
"error_type": "unexpected_service_error",
|
94
|
+
"message": str(e),
|
95
|
+
"operation": "get_document_metadata",
|
96
|
+
}
|
97
|
+
|
98
|
+
def get_document_content_as_markdown(self, document_id: str) -> dict[str, Any] | None:
|
99
|
+
"""
|
100
|
+
Retrieves the content of a Google Document as Markdown using Drive API export.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
document_id: The ID of the Google Document (also used as Drive file ID).
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
A dictionary with 'document_id' and 'markdown_content', or an error dictionary.
|
107
|
+
"""
|
108
|
+
try:
|
109
|
+
logger.info(f"Attempting to export document ID: {document_id} as Markdown via Drive API.")
|
110
|
+
|
111
|
+
# Obtain credentials
|
112
|
+
credentials = gauth.get_credentials()
|
113
|
+
if not credentials:
|
114
|
+
logger.error("Failed to obtain credentials for Drive API export.")
|
115
|
+
return {
|
116
|
+
"error": True,
|
117
|
+
"error_type": "authentication_error",
|
118
|
+
"message": "Failed to obtain credentials.",
|
119
|
+
"operation": "get_document_content_as_markdown",
|
120
|
+
}
|
121
|
+
|
122
|
+
# Build a temporary Drive service client
|
123
|
+
drive_service_client = build("drive", "v3", credentials=credentials)
|
124
|
+
|
125
|
+
# Attempt to export as 'text/markdown'
|
126
|
+
# Not all environments or GDoc content might support 'text/markdown' perfectly.
|
127
|
+
# 'text/plain' is a safer fallback.
|
128
|
+
request = drive_service_client.files().export_media(fileId=document_id, mimeType="text/markdown")
|
129
|
+
fh = io.BytesIO()
|
130
|
+
downloader = MediaIoBaseDownload(fh, request)
|
131
|
+
done = False
|
132
|
+
content_bytes = None
|
133
|
+
while done is False:
|
134
|
+
status, done = downloader.next_chunk()
|
135
|
+
logger.debug(f"Download progress: {int(status.progress() * 100)}%")
|
136
|
+
content_bytes = fh.getvalue()
|
137
|
+
|
138
|
+
if content_bytes is None:
|
139
|
+
raise Exception("Failed to download exported content (bytes object is None).")
|
140
|
+
|
141
|
+
markdown_content = content_bytes.decode("utf-8")
|
142
|
+
logger.info(f"Successfully exported document ID: {document_id} to Markdown.")
|
143
|
+
return {"document_id": document_id, "markdown_content": markdown_content}
|
144
|
+
|
145
|
+
except HttpError as error:
|
146
|
+
# If markdown export fails, could try 'text/plain' as fallback or just report error
|
147
|
+
logger.warning(
|
148
|
+
f"HTTPError exporting document {document_id} as Markdown: {error}. Falling back to text/plain might be an option if this is a common issue for certain docs."
|
149
|
+
)
|
150
|
+
# For now, just return the error from the attempt.
|
151
|
+
return self.handle_api_error("get_document_content_as_markdown_drive_export", error)
|
152
|
+
except Exception as e:
|
153
|
+
logger.exception(f"Unexpected error exporting document {document_id} as Markdown: {e}")
|
154
|
+
return {
|
155
|
+
"error": True,
|
156
|
+
"error_type": "export_error",
|
157
|
+
"message": str(e),
|
158
|
+
"operation": "get_document_content_as_markdown",
|
159
|
+
}
|
160
|
+
|
161
|
+
def append_text(self, document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any] | None:
|
162
|
+
"""
|
163
|
+
Appends text to the end of a Google Document.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
document_id: The ID of the Google Document.
|
167
|
+
text: The text to append.
|
168
|
+
ensure_newline: If True and the document is not empty, prepends a newline to the text.
|
169
|
+
|
170
|
+
Returns:
|
171
|
+
A dictionary indicating success or an error dictionary.
|
172
|
+
"""
|
173
|
+
try:
|
174
|
+
logger.info(f"Appending text to document ID: {document_id}. ensure_newline={ensure_newline}")
|
175
|
+
|
176
|
+
# To append at the end, we need to find the current end of the body segment.
|
177
|
+
# Get document to find end index. Fields "body(content(endIndex))" might be enough.
|
178
|
+
# A simpler approach for "append" is often to insert at the existing end index of the body.
|
179
|
+
# If the document is empty, Docs API might create a paragraph.
|
180
|
+
# If ensure_newline, and doc is not empty, prepend "\n" to text.
|
181
|
+
|
182
|
+
document = (
|
183
|
+
self.service.documents().get(documentId=document_id, fields="body(content(endIndex,paragraph))").execute()
|
184
|
+
)
|
185
|
+
end_index = (
|
186
|
+
document.get("body", {}).get("content", [])[-1].get("endIndex")
|
187
|
+
if document.get("body", {}).get("content")
|
188
|
+
else 1
|
189
|
+
)
|
190
|
+
|
191
|
+
text_to_insert = text
|
192
|
+
if ensure_newline and end_index > 1: # A new doc might have an end_index of 1 for the initial implicit paragraph
|
193
|
+
text_to_insert = "\n" + text
|
194
|
+
|
195
|
+
requests = [
|
196
|
+
{
|
197
|
+
"insertText": {
|
198
|
+
"location": {
|
199
|
+
"index": end_index - 1 # Insert before the final newline of the document/body
|
200
|
+
},
|
201
|
+
"text": text_to_insert,
|
202
|
+
}
|
203
|
+
}
|
204
|
+
]
|
205
|
+
|
206
|
+
self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
|
207
|
+
logger.info(f"Successfully appended text to document ID: {document_id}")
|
208
|
+
return {
|
209
|
+
"document_id": document_id,
|
210
|
+
"success": True,
|
211
|
+
"operation": "append_text",
|
212
|
+
}
|
213
|
+
except HttpError as error:
|
214
|
+
logger.error(f"Error appending text to document {document_id}: {error}")
|
215
|
+
return self.handle_api_error("append_text", error)
|
216
|
+
except Exception as e:
|
217
|
+
logger.exception(f"Unexpected error appending text to document {document_id}")
|
218
|
+
return {
|
219
|
+
"error": True,
|
220
|
+
"error_type": "unexpected_service_error",
|
221
|
+
"message": str(e),
|
222
|
+
"operation": "append_text",
|
223
|
+
}
|
224
|
+
|
225
|
+
def prepend_text(self, document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any] | None:
|
226
|
+
"""
|
227
|
+
Prepends text to the beginning of a Google Document.
|
228
|
+
|
229
|
+
Args:
|
230
|
+
document_id: The ID of the Google Document.
|
231
|
+
text: The text to prepend.
|
232
|
+
ensure_newline: If True and the document is not empty, appends a newline to the text.
|
233
|
+
|
234
|
+
Returns:
|
235
|
+
A dictionary indicating success or an error dictionary.
|
236
|
+
"""
|
237
|
+
try:
|
238
|
+
logger.info(f"Prepending text to document ID: {document_id}. ensure_newline={ensure_newline}")
|
239
|
+
|
240
|
+
text_to_insert = text
|
241
|
+
# To prepend, we generally insert at index 1 (after the initial Body segment start).
|
242
|
+
# If ensure_newline is true, and the document isn't empty, add a newline *after* the prepended text.
|
243
|
+
# This requires checking if the document has existing content.
|
244
|
+
if ensure_newline:
|
245
|
+
document = self.service.documents().get(documentId=document_id, fields="body(content(endIndex))").execute()
|
246
|
+
current_content_exists = bool(document.get("body", {}).get("content"))
|
247
|
+
if current_content_exists:
|
248
|
+
text_to_insert = text + "\n"
|
249
|
+
|
250
|
+
requests = [
|
251
|
+
{
|
252
|
+
"insertText": {
|
253
|
+
"location": {
|
254
|
+
# Index 1 is typically the beginning of the document body content.
|
255
|
+
"index": 1
|
256
|
+
},
|
257
|
+
"text": text_to_insert,
|
258
|
+
}
|
259
|
+
}
|
260
|
+
]
|
261
|
+
|
262
|
+
self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
|
263
|
+
logger.info(f"Successfully prepended text to document ID: {document_id}")
|
264
|
+
return {
|
265
|
+
"document_id": document_id,
|
266
|
+
"success": True,
|
267
|
+
"operation": "prepend_text",
|
268
|
+
}
|
269
|
+
except HttpError as error:
|
270
|
+
logger.error(f"Error prepending text to document {document_id}: {error}")
|
271
|
+
return self.handle_api_error("prepend_text", error)
|
272
|
+
except Exception as e:
|
273
|
+
logger.exception(f"Unexpected error prepending text to document {document_id}")
|
274
|
+
return {
|
275
|
+
"error": True,
|
276
|
+
"error_type": "unexpected_service_error",
|
277
|
+
"message": str(e),
|
278
|
+
"operation": "prepend_text",
|
279
|
+
}
|
280
|
+
|
281
|
+
def insert_text(
|
282
|
+
self,
|
283
|
+
document_id: str,
|
284
|
+
text: str,
|
285
|
+
index: int | None = None,
|
286
|
+
segment_id: str | None = None,
|
287
|
+
) -> dict[str, Any] | None:
|
288
|
+
"""
|
289
|
+
Inserts text at a specific location in a Google Document.
|
290
|
+
|
291
|
+
Args:
|
292
|
+
document_id: The ID of the Google Document.
|
293
|
+
text: The text to insert.
|
294
|
+
index: Optional. The 0-based index where the text should be inserted within the segment.
|
295
|
+
If None or for the main body, typically 1 for the beginning of content.
|
296
|
+
Behavior depends on document structure; precise indexing requires knowledge of element endIndices.
|
297
|
+
If inserting into an empty body, index 1 is typically used.
|
298
|
+
segment_id: Optional. The ID of the header, footer, footnote, or inline object body.
|
299
|
+
If None or empty, targets the main document body.
|
300
|
+
|
301
|
+
Returns:
|
302
|
+
A dictionary indicating success or an error dictionary.
|
303
|
+
"""
|
304
|
+
try:
|
305
|
+
logger.info(f"Inserting text into document ID: {document_id} at index: {index}, segment: {segment_id}")
|
306
|
+
|
307
|
+
# Default to index 1 if not provided, which usually targets the start of the body content.
|
308
|
+
# For an empty document, this effectively adds text.
|
309
|
+
# For precise insertion into existing content, caller needs to provide a valid index.
|
310
|
+
insert_location_index = index if index is not None else 1
|
311
|
+
|
312
|
+
request = {
|
313
|
+
"insertText": {
|
314
|
+
"location": {"index": insert_location_index},
|
315
|
+
"text": text,
|
316
|
+
}
|
317
|
+
}
|
318
|
+
# Only add segmentId to location if it's provided
|
319
|
+
if segment_id:
|
320
|
+
request["insertText"]["location"]["segmentId"] = segment_id
|
321
|
+
|
322
|
+
requests = [request]
|
323
|
+
|
324
|
+
self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
|
325
|
+
logger.info(f"Successfully inserted text into document ID: {document_id}")
|
326
|
+
return {
|
327
|
+
"document_id": document_id,
|
328
|
+
"success": True,
|
329
|
+
"operation": "insert_text",
|
330
|
+
}
|
331
|
+
except HttpError as error:
|
332
|
+
logger.error(f"Error inserting text into document {document_id}: {error}")
|
333
|
+
return self.handle_api_error("insert_text", error)
|
334
|
+
except Exception as e:
|
335
|
+
logger.exception(f"Unexpected error inserting text into document {document_id}")
|
336
|
+
return {
|
337
|
+
"error": True,
|
338
|
+
"error_type": "unexpected_service_error",
|
339
|
+
"message": str(e),
|
340
|
+
"operation": "insert_text",
|
341
|
+
}
|
342
|
+
|
343
|
+
def batch_update(self, document_id: str, requests: list[dict]) -> dict[str, Any] | None:
|
344
|
+
"""
|
345
|
+
Applies a list of update requests to the specified Google Document.
|
346
|
+
|
347
|
+
Args:
|
348
|
+
document_id: The ID of the Google Document.
|
349
|
+
requests: A list of Google Docs API request objects as dictionaries.
|
350
|
+
(e.g., InsertTextRequest, DeleteContentRangeRequest, etc.)
|
351
|
+
|
352
|
+
Returns:
|
353
|
+
The response from the batchUpdate API call (contains replies for each request)
|
354
|
+
or an error dictionary.
|
355
|
+
"""
|
356
|
+
try:
|
357
|
+
logger.info(f"Executing batchUpdate for document ID: {document_id} with {len(requests)} requests.")
|
358
|
+
if not requests:
|
359
|
+
logger.warning(f"batchUpdate called with no requests for document ID: {document_id}")
|
360
|
+
return {
|
361
|
+
"document_id": document_id,
|
362
|
+
"replies": [],
|
363
|
+
"message": "No requests provided.",
|
364
|
+
}
|
365
|
+
|
366
|
+
response = self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
|
367
|
+
# The response object contains a list of 'replies', one for each request,
|
368
|
+
# or it might be empty if all requests were successful without specific replies.
|
369
|
+
# It also contains 'writeControl' and 'documentId'.
|
370
|
+
logger.info(
|
371
|
+
f"Successfully executed batchUpdate for document ID: {document_id}. Response contains {len(response.get('replies', []))} replies."
|
372
|
+
)
|
373
|
+
return {
|
374
|
+
"document_id": response.get("documentId"),
|
375
|
+
"replies": response.get("replies", []),
|
376
|
+
"write_control": response.get("writeControl"),
|
377
|
+
}
|
378
|
+
except HttpError as error:
|
379
|
+
logger.error(f"Error during batchUpdate for document {document_id}: {error}")
|
380
|
+
return self.handle_api_error("batch_update", error)
|
381
|
+
except Exception as e:
|
382
|
+
logger.exception(f"Unexpected error during batchUpdate for document {document_id}")
|
383
|
+
return {
|
384
|
+
"error": True,
|
385
|
+
"error_type": "unexpected_service_error",
|
386
|
+
"message": str(e),
|
387
|
+
"operation": "batch_update",
|
388
|
+
}
|