arcade-google 0.1.6__py3-none-any.whl → 1.2.4__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.
- arcade_google/constants.py +24 -0
- arcade_google/critics.py +41 -0
- arcade_google/doc_to_html.py +99 -0
- arcade_google/doc_to_markdown.py +64 -0
- arcade_google/enums.py +0 -0
- arcade_google/exceptions.py +70 -0
- arcade_google/models.py +654 -0
- arcade_google/tools/__init__.py +96 -1
- arcade_google/tools/calendar.py +236 -32
- arcade_google/tools/contacts.py +96 -0
- arcade_google/tools/docs.py +24 -14
- arcade_google/tools/drive.py +256 -48
- arcade_google/tools/file_picker.py +54 -0
- arcade_google/tools/gmail.py +336 -116
- arcade_google/tools/sheets.py +144 -0
- arcade_google/utils.py +1564 -0
- arcade_google-1.2.4.dist-info/METADATA +26 -0
- arcade_google-1.2.4.dist-info/RECORD +21 -0
- {arcade_google-0.1.6.dist-info → arcade_google-1.2.4.dist-info}/WHEEL +1 -1
- arcade_google-1.2.4.dist-info/licenses/LICENSE +21 -0
- arcade_google/tools/models.py +0 -296
- arcade_google/tools/utils.py +0 -282
- arcade_google-0.1.6.dist-info/METADATA +0 -20
- arcade_google-0.1.6.dist-info/RECORD +0 -11
arcade_google/utils.py
ADDED
|
@@ -0,0 +1,1564 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
|
4
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
5
|
+
from email.message import EmailMessage
|
|
6
|
+
from email.mime.text import MIMEText
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from arcade_tdk import ToolContext
|
|
12
|
+
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
from google.oauth2.credentials import Credentials
|
|
15
|
+
from googleapiclient.discovery import Resource, build
|
|
16
|
+
|
|
17
|
+
from arcade_google.constants import (
|
|
18
|
+
DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
19
|
+
DEFAULT_SHEET_COLUMN_COUNT,
|
|
20
|
+
DEFAULT_SHEET_ROW_COUNT,
|
|
21
|
+
)
|
|
22
|
+
from arcade_google.exceptions import GmailToolError, GoogleServiceError
|
|
23
|
+
from arcade_google.models import (
|
|
24
|
+
CellData,
|
|
25
|
+
CellExtendedValue,
|
|
26
|
+
CellFormat,
|
|
27
|
+
CellValue,
|
|
28
|
+
Corpora,
|
|
29
|
+
Day,
|
|
30
|
+
GmailAction,
|
|
31
|
+
GmailReplyToWhom,
|
|
32
|
+
GridData,
|
|
33
|
+
GridProperties,
|
|
34
|
+
NumberFormat,
|
|
35
|
+
NumberFormatType,
|
|
36
|
+
OrderBy,
|
|
37
|
+
RowData,
|
|
38
|
+
Sheet,
|
|
39
|
+
SheetDataInput,
|
|
40
|
+
SheetProperties,
|
|
41
|
+
TimeSlot,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
## Set up basic configuration for logging to the console with DEBUG level and a specific format.
|
|
45
|
+
logging.basicConfig(
|
|
46
|
+
level=logging.DEBUG,
|
|
47
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_datetime(datetime_str: str, time_zone: str) -> datetime:
|
|
54
|
+
"""
|
|
55
|
+
Parse a datetime string in ISO 8601 format and ensure it is timezone-aware.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
datetime_str (str): The datetime string to parse. Expected format: 'YYYY-MM-DDTHH:MM:SS'.
|
|
59
|
+
time_zone (str): The timezone to apply if the datetime string is naive.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
datetime: A timezone-aware datetime object.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the datetime string is not in the correct format.
|
|
66
|
+
"""
|
|
67
|
+
datetime_str = datetime_str.upper().strip().rstrip("Z")
|
|
68
|
+
try:
|
|
69
|
+
dt = datetime.fromisoformat(datetime_str)
|
|
70
|
+
if dt.tzinfo is None:
|
|
71
|
+
dt = dt.replace(tzinfo=ZoneInfo(time_zone))
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Invalid datetime format: '{datetime_str}'. "
|
|
75
|
+
"Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'."
|
|
76
|
+
) from e
|
|
77
|
+
return dt
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DateRange(Enum):
|
|
81
|
+
TODAY = "today"
|
|
82
|
+
YESTERDAY = "yesterday"
|
|
83
|
+
LAST_7_DAYS = "last_7_days"
|
|
84
|
+
LAST_30_DAYS = "last_30_days"
|
|
85
|
+
THIS_MONTH = "this_month"
|
|
86
|
+
LAST_MONTH = "last_month"
|
|
87
|
+
THIS_YEAR = "this_year"
|
|
88
|
+
|
|
89
|
+
def to_date_query(self) -> str:
|
|
90
|
+
today = datetime.now()
|
|
91
|
+
result = "after:"
|
|
92
|
+
comparison_date = today
|
|
93
|
+
|
|
94
|
+
if self == DateRange.YESTERDAY:
|
|
95
|
+
comparison_date = today - timedelta(days=1)
|
|
96
|
+
elif self == DateRange.LAST_7_DAYS:
|
|
97
|
+
comparison_date = today - timedelta(days=7)
|
|
98
|
+
elif self == DateRange.LAST_30_DAYS:
|
|
99
|
+
comparison_date = today - timedelta(days=30)
|
|
100
|
+
elif self == DateRange.THIS_MONTH:
|
|
101
|
+
comparison_date = today.replace(day=1)
|
|
102
|
+
elif self == DateRange.LAST_MONTH:
|
|
103
|
+
comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
|
|
104
|
+
elif self == DateRange.THIS_YEAR:
|
|
105
|
+
comparison_date = today.replace(month=1, day=1)
|
|
106
|
+
elif self == DateRange.LAST_MONTH:
|
|
107
|
+
comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace(
|
|
108
|
+
month=1, day=1
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return result + comparison_date.strftime("%Y/%m/%d")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_email_message(
|
|
115
|
+
recipient: str,
|
|
116
|
+
subject: str,
|
|
117
|
+
body: str,
|
|
118
|
+
cc: list[str] | None = None,
|
|
119
|
+
bcc: list[str] | None = None,
|
|
120
|
+
replying_to: dict[str, Any] | None = None,
|
|
121
|
+
action: GmailAction = GmailAction.SEND,
|
|
122
|
+
) -> dict[str, Any]:
|
|
123
|
+
if replying_to:
|
|
124
|
+
body = build_reply_body(body, replying_to)
|
|
125
|
+
|
|
126
|
+
message: EmailMessage | MIMEText
|
|
127
|
+
|
|
128
|
+
if action == GmailAction.SEND:
|
|
129
|
+
message = EmailMessage()
|
|
130
|
+
message.set_content(body)
|
|
131
|
+
elif action == GmailAction.DRAFT:
|
|
132
|
+
message = MIMEText(body)
|
|
133
|
+
|
|
134
|
+
message["To"] = recipient
|
|
135
|
+
message["Subject"] = subject
|
|
136
|
+
|
|
137
|
+
if cc:
|
|
138
|
+
message["Cc"] = ",".join(cc)
|
|
139
|
+
if bcc:
|
|
140
|
+
message["Bcc"] = ",".join(bcc)
|
|
141
|
+
if replying_to:
|
|
142
|
+
message["In-Reply-To"] = replying_to["header_message_id"]
|
|
143
|
+
message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}"
|
|
144
|
+
|
|
145
|
+
encoded_message = urlsafe_b64encode(message.as_bytes()).decode()
|
|
146
|
+
|
|
147
|
+
data = {"raw": encoded_message}
|
|
148
|
+
|
|
149
|
+
if replying_to:
|
|
150
|
+
data["threadId"] = replying_to["thread_id"]
|
|
151
|
+
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_reply_body(body: str, replying_to: dict[str, Any]) -> str:
|
|
156
|
+
attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:"
|
|
157
|
+
lines = replying_to["plain_text_body"].split("\n")
|
|
158
|
+
quoted_plain = "\n".join([f"> {line}" for line in lines])
|
|
159
|
+
return f"{body}\n\n{attribution}\n\n{quoted_plain}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_reply_recipients(
|
|
163
|
+
replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom
|
|
164
|
+
) -> str:
|
|
165
|
+
if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER:
|
|
166
|
+
recipients = [replying_to["from"]]
|
|
167
|
+
elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT:
|
|
168
|
+
recipients = [replying_to["from"], *replying_to["to"].split(",")]
|
|
169
|
+
else:
|
|
170
|
+
raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}")
|
|
171
|
+
|
|
172
|
+
recipients = [
|
|
173
|
+
email_address.strip()
|
|
174
|
+
for email_address in recipients
|
|
175
|
+
if email_address.strip().lower() != current_user_email_address.lower().strip()
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
return ", ".join(recipients)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]:
|
|
182
|
+
"""
|
|
183
|
+
Parse email data and extract relevant information.
|
|
184
|
+
Only returns the plain text body.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
email_data (dict[str, Any]): Raw email data from Gmail API.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
dict[str, str]: Parsed email details
|
|
191
|
+
"""
|
|
192
|
+
payload = email_data.get("payload", {})
|
|
193
|
+
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
|
|
194
|
+
|
|
195
|
+
body_data = _get_email_plain_text_body(payload)
|
|
196
|
+
|
|
197
|
+
email_details = {
|
|
198
|
+
"id": email_data.get("id", ""),
|
|
199
|
+
"thread_id": email_data.get("threadId", ""),
|
|
200
|
+
"label_ids": email_data.get("labelIds", []),
|
|
201
|
+
"history_id": email_data.get("historyId", ""),
|
|
202
|
+
"snippet": email_data.get("snippet", ""),
|
|
203
|
+
"to": headers.get("to", ""),
|
|
204
|
+
"cc": headers.get("cc", ""),
|
|
205
|
+
"from": headers.get("from", ""),
|
|
206
|
+
"reply_to": headers.get("reply-to", ""),
|
|
207
|
+
"in_reply_to": headers.get("in-reply-to", ""),
|
|
208
|
+
"references": headers.get("references", ""),
|
|
209
|
+
"header_message_id": headers.get("message-id", ""),
|
|
210
|
+
"date": headers.get("date", ""),
|
|
211
|
+
"subject": headers.get("subject", ""),
|
|
212
|
+
"body": body_data or "",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return email_details
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]:
|
|
219
|
+
"""
|
|
220
|
+
Parse email data and extract relevant information.
|
|
221
|
+
Returns the plain text and HTML body along with the images.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
email_data (Dict[str, Any]): Raw email data from Gmail API.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
dict[str, Any]: Parsed email details
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
payload = email_data.get("payload", {})
|
|
231
|
+
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
|
|
232
|
+
|
|
233
|
+
# Extract different parts of the email
|
|
234
|
+
plain_text_body = _get_email_plain_text_body(payload)
|
|
235
|
+
html_body = _get_email_html_body(payload)
|
|
236
|
+
|
|
237
|
+
email_details = {
|
|
238
|
+
"id": email_data.get("id", ""),
|
|
239
|
+
"thread_id": email_data.get("threadId", ""),
|
|
240
|
+
"label_ids": email_data.get("labelIds", []),
|
|
241
|
+
"history_id": email_data.get("historyId", ""),
|
|
242
|
+
"snippet": email_data.get("snippet", ""),
|
|
243
|
+
"to": headers.get("to", ""),
|
|
244
|
+
"cc": headers.get("cc", ""),
|
|
245
|
+
"from": headers.get("from", ""),
|
|
246
|
+
"reply_to": headers.get("reply-to", ""),
|
|
247
|
+
"in_reply_to": headers.get("in-reply-to", ""),
|
|
248
|
+
"references": headers.get("references", ""),
|
|
249
|
+
"header_message_id": headers.get("message-id", ""),
|
|
250
|
+
"date": headers.get("date", ""),
|
|
251
|
+
"subject": headers.get("subject", ""),
|
|
252
|
+
"plain_text_body": plain_text_body or _clean_email_body(html_body),
|
|
253
|
+
"html_body": html_body or "",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return email_details
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]:
|
|
260
|
+
"""
|
|
261
|
+
Parse draft email data and extract relevant information.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
dict[str, str]: Parsed draft email details
|
|
268
|
+
"""
|
|
269
|
+
message = draft_email_data.get("message", {})
|
|
270
|
+
payload = message.get("payload", {})
|
|
271
|
+
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
|
|
272
|
+
|
|
273
|
+
body_data = _get_email_plain_text_body(payload)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"id": draft_email_data.get("id", ""),
|
|
277
|
+
"thread_id": draft_email_data.get("threadId", ""),
|
|
278
|
+
"from": headers.get("from", ""),
|
|
279
|
+
"date": headers.get("internaldate", ""),
|
|
280
|
+
"subject": headers.get("subject", ""),
|
|
281
|
+
"body": _clean_email_body(body_data) if body_data else "",
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_draft_url(draft_id: str) -> str:
|
|
286
|
+
return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_sent_email_url(sent_email_id: str) -> str:
|
|
290
|
+
return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
294
|
+
"""
|
|
295
|
+
Retrieves full message data for each message ID in the given list and extracts email details.
|
|
296
|
+
|
|
297
|
+
:param service: Authenticated Gmail API service instance.
|
|
298
|
+
:param messages: A list of dictionaries, each representing a message with an 'id' key.
|
|
299
|
+
:return: A list of dictionaries, each containing parsed email details.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
emails = []
|
|
303
|
+
for msg in messages:
|
|
304
|
+
try:
|
|
305
|
+
# Fetch the full message data from Gmail using the message ID
|
|
306
|
+
email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
|
|
307
|
+
# Parse the raw email data into a structured form
|
|
308
|
+
email_details = parse_plain_text_email(email_data)
|
|
309
|
+
# Only add the details if parsing was successful
|
|
310
|
+
if email_details:
|
|
311
|
+
emails.append(email_details)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
# Log any errors encountered while trying to fetch or parse a message
|
|
314
|
+
raise GmailToolError(
|
|
315
|
+
message=f"Error reading email {msg['id']}.", developer_message=str(e)
|
|
316
|
+
)
|
|
317
|
+
return emails
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_email_in_trash_url(email_id: str) -> str:
|
|
321
|
+
return f"https://mail.google.com/mail/u/0/#trash/{email_id}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _build_gmail_service(context: ToolContext) -> Any:
|
|
325
|
+
"""
|
|
326
|
+
Private helper function to build and return the Gmail service client.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
context (ToolContext): The context containing authorization details.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
googleapiclient.discovery.Resource: An authorized Gmail API service instance.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
credentials = Credentials(
|
|
336
|
+
context.authorization.token
|
|
337
|
+
if context.authorization and context.authorization.token
|
|
338
|
+
else ""
|
|
339
|
+
)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
raise GoogleServiceError(message="Failed to build Gmail service.", developer_message=str(e))
|
|
342
|
+
|
|
343
|
+
return build("gmail", "v1", credentials=credentials)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _extract_plain_body(parts: list) -> str | None:
|
|
347
|
+
"""
|
|
348
|
+
Recursively extract the email body from parts, handling both plain text and HTML.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
parts (List[Dict[str, Any]]): List of email parts.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
str | None: Decoded and cleaned email body or None if not found.
|
|
355
|
+
"""
|
|
356
|
+
for part in parts:
|
|
357
|
+
mime_type = part.get("mimeType")
|
|
358
|
+
|
|
359
|
+
if mime_type == "text/plain" and "data" in part.get("body", {}):
|
|
360
|
+
return urlsafe_b64decode(part["body"]["data"]).decode()
|
|
361
|
+
|
|
362
|
+
elif mime_type.startswith("multipart/"):
|
|
363
|
+
subparts = part.get("parts", [])
|
|
364
|
+
body = _extract_plain_body(subparts)
|
|
365
|
+
if body:
|
|
366
|
+
return body
|
|
367
|
+
|
|
368
|
+
return _extract_html_body(parts)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _extract_html_body(parts: list) -> str | None:
|
|
372
|
+
"""
|
|
373
|
+
Recursively extract the email body from parts, handling only HTML.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
parts (List[Dict[str, Any]]): List of email parts.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
str | None: Decoded and cleaned email body or None if not found.
|
|
380
|
+
"""
|
|
381
|
+
for part in parts:
|
|
382
|
+
mime_type = part.get("mimeType")
|
|
383
|
+
|
|
384
|
+
if mime_type == "text/html" and "data" in part.get("body", {}):
|
|
385
|
+
html_content = urlsafe_b64decode(part["body"]["data"]).decode()
|
|
386
|
+
return html_content
|
|
387
|
+
|
|
388
|
+
elif mime_type.startswith("multipart/"):
|
|
389
|
+
subparts = part.get("parts", [])
|
|
390
|
+
body = _extract_html_body(subparts)
|
|
391
|
+
if body:
|
|
392
|
+
return body
|
|
393
|
+
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _get_email_images(payload: dict[str, Any]) -> list[str] | None:
|
|
398
|
+
"""
|
|
399
|
+
Extract the email images from an email payload.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
payload (Dict[str, Any]): Email payload data.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
list[str] | None: List of decoded image contents or None if none found.
|
|
406
|
+
"""
|
|
407
|
+
images = []
|
|
408
|
+
for part in payload.get("parts", []):
|
|
409
|
+
mime_type = part.get("mimeType")
|
|
410
|
+
|
|
411
|
+
if mime_type.startswith("image/") and "data" in part.get("body", {}):
|
|
412
|
+
image_content = part["body"]["data"]
|
|
413
|
+
images.append(image_content)
|
|
414
|
+
|
|
415
|
+
elif mime_type.startswith("multipart/"):
|
|
416
|
+
subparts = part.get("parts", [])
|
|
417
|
+
subimages = _get_email_images(subparts)
|
|
418
|
+
if subimages:
|
|
419
|
+
images.extend(subimages)
|
|
420
|
+
|
|
421
|
+
if images:
|
|
422
|
+
return images
|
|
423
|
+
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None:
|
|
428
|
+
"""
|
|
429
|
+
Extract email body from payload, handling 'multipart/alternative' parts.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
payload (Dict[str, Any]): Email payload data.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
str | None: Decoded email body or None if not found.
|
|
436
|
+
"""
|
|
437
|
+
# Direct body extraction
|
|
438
|
+
if "body" in payload and payload["body"].get("data"):
|
|
439
|
+
return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode())
|
|
440
|
+
|
|
441
|
+
# Handle multipart and alternative parts
|
|
442
|
+
return _clean_email_body(_extract_plain_body(payload.get("parts", [])))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _get_email_html_body(payload: dict[str, Any]) -> str | None:
|
|
446
|
+
"""
|
|
447
|
+
Extract email html body from payload, handling 'multipart/alternative' parts.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
payload (Dict[str, Any]): Email payload data.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
str | None: Decoded email body or None if not found.
|
|
454
|
+
"""
|
|
455
|
+
# Direct body extraction
|
|
456
|
+
if "body" in payload and payload["body"].get("data"):
|
|
457
|
+
return urlsafe_b64decode(payload["body"]["data"]).decode()
|
|
458
|
+
|
|
459
|
+
# Handle multipart and alternative parts
|
|
460
|
+
return _extract_html_body(payload.get("parts", []))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _clean_email_body(body: str | None) -> str:
|
|
464
|
+
"""
|
|
465
|
+
Remove HTML tags and clean up email body text while preserving most content.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
body (str): The raw email body text.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
str: Cleaned email body text.
|
|
472
|
+
"""
|
|
473
|
+
if not body:
|
|
474
|
+
return ""
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
# Remove HTML tags using BeautifulSoup
|
|
478
|
+
soup = BeautifulSoup(body, "html.parser")
|
|
479
|
+
text = soup.get_text(separator=" ")
|
|
480
|
+
|
|
481
|
+
# Clean up the text
|
|
482
|
+
cleaned_text = _clean_text(text)
|
|
483
|
+
|
|
484
|
+
return cleaned_text.strip()
|
|
485
|
+
except Exception:
|
|
486
|
+
logger.exception("Error cleaning email body")
|
|
487
|
+
return body
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _clean_text(text: str) -> str:
|
|
491
|
+
"""
|
|
492
|
+
Clean up the text while preserving most content.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
text (str): The input text.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
str: Cleaned text.
|
|
499
|
+
"""
|
|
500
|
+
# Replace multiple newlines with a single newline
|
|
501
|
+
text = re.sub(r"\n+", "\n", text)
|
|
502
|
+
|
|
503
|
+
# Replace multiple spaces with a single space
|
|
504
|
+
text = re.sub(r"\s+", " ", text)
|
|
505
|
+
|
|
506
|
+
# Remove leading/trailing whitespace from each line
|
|
507
|
+
text = "\n".join(line.strip() for line in text.split("\n"))
|
|
508
|
+
|
|
509
|
+
return text
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _update_datetime(day: Day | None, time: TimeSlot | None, time_zone: str) -> dict | None:
|
|
513
|
+
"""
|
|
514
|
+
Update the datetime for a Google Calendar event.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
day (Day | None): The day of the event.
|
|
518
|
+
time (TimeSlot | None): The time of the event.
|
|
519
|
+
time_zone (str): The time zone of the event.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
dict | None: The updated datetime for the event.
|
|
523
|
+
"""
|
|
524
|
+
if day and time:
|
|
525
|
+
dt = datetime.combine(day.to_date(time_zone), time.to_time())
|
|
526
|
+
return {"dateTime": dt.isoformat(), "timeZone": time_zone}
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def build_gmail_query_string(
|
|
531
|
+
sender: str | None = None,
|
|
532
|
+
recipient: str | None = None,
|
|
533
|
+
subject: str | None = None,
|
|
534
|
+
body: str | None = None,
|
|
535
|
+
date_range: DateRange | None = None,
|
|
536
|
+
label: str | None = None,
|
|
537
|
+
) -> str:
|
|
538
|
+
"""Helper function to build a query string
|
|
539
|
+
for Gmail list_emails_by_header and search_threads tools.
|
|
540
|
+
"""
|
|
541
|
+
query = []
|
|
542
|
+
if sender:
|
|
543
|
+
query.append(f"from:{sender}")
|
|
544
|
+
if recipient:
|
|
545
|
+
query.append(f"to:{recipient}")
|
|
546
|
+
if subject:
|
|
547
|
+
query.append(f"subject:{subject}")
|
|
548
|
+
if body:
|
|
549
|
+
query.append(body)
|
|
550
|
+
if date_range:
|
|
551
|
+
query.append(date_range.to_date_query())
|
|
552
|
+
if label:
|
|
553
|
+
query.append(f"label:{label}")
|
|
554
|
+
return " ".join(query)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]:
|
|
558
|
+
"""
|
|
559
|
+
Retrieve label IDs for given label names.
|
|
560
|
+
Returns a dictionary mapping label names to their IDs.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
service: Authenticated Gmail API service instance.
|
|
564
|
+
label_names: List of label names to retrieve IDs for.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
A dictionary mapping found label names to their corresponding IDs.
|
|
568
|
+
"""
|
|
569
|
+
try:
|
|
570
|
+
# Fetch all existing labels from Gmail
|
|
571
|
+
labels = service.users().labels().list(userId="me").execute().get("labels", [])
|
|
572
|
+
except Exception as e:
|
|
573
|
+
raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e
|
|
574
|
+
|
|
575
|
+
# Create a mapping from label names to their IDs
|
|
576
|
+
label_id_map = {label["name"]: label["id"] for label in labels}
|
|
577
|
+
|
|
578
|
+
found_labels = {}
|
|
579
|
+
for name in label_names:
|
|
580
|
+
label_id = label_id_map.get(name)
|
|
581
|
+
if label_id:
|
|
582
|
+
found_labels[name] = label_id
|
|
583
|
+
else:
|
|
584
|
+
logger.warning(f"Label '{name}' does not exist")
|
|
585
|
+
|
|
586
|
+
return found_labels
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]:
|
|
590
|
+
"""
|
|
591
|
+
Helper function to fetch messages from Gmail API for the list_emails_by_header tool.
|
|
592
|
+
"""
|
|
593
|
+
response = (
|
|
594
|
+
service.users()
|
|
595
|
+
.messages()
|
|
596
|
+
.list(userId="me", q=query_string, maxResults=limit or 100)
|
|
597
|
+
.execute()
|
|
598
|
+
)
|
|
599
|
+
return response.get("messages", []) # type: ignore[no-any-return]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def remove_none_values(params: dict) -> dict:
|
|
603
|
+
"""
|
|
604
|
+
Remove None values from a dictionary.
|
|
605
|
+
:param params: The dictionary to clean
|
|
606
|
+
:return: A new dictionary with None values removed
|
|
607
|
+
"""
|
|
608
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# Drive utils
|
|
612
|
+
def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
613
|
+
"""
|
|
614
|
+
Build a Drive service object.
|
|
615
|
+
"""
|
|
616
|
+
auth_token = auth_token or ""
|
|
617
|
+
return build("drive", "v3", credentials=Credentials(auth_token))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def build_files_list_query(
|
|
621
|
+
mime_type: str,
|
|
622
|
+
document_contains: list[str] | None = None,
|
|
623
|
+
document_not_contains: list[str] | None = None,
|
|
624
|
+
) -> str:
|
|
625
|
+
query = [f"(mimeType = '{mime_type}' and trashed = false)"]
|
|
626
|
+
|
|
627
|
+
if isinstance(document_contains, str):
|
|
628
|
+
document_contains = [document_contains]
|
|
629
|
+
|
|
630
|
+
if isinstance(document_not_contains, str):
|
|
631
|
+
document_not_contains = [document_not_contains]
|
|
632
|
+
|
|
633
|
+
if document_contains:
|
|
634
|
+
for keyword in document_contains:
|
|
635
|
+
name_contains = keyword.replace("'", "\\'")
|
|
636
|
+
full_text_contains = keyword.replace("'", "\\'")
|
|
637
|
+
keyword_query = (
|
|
638
|
+
f"(name contains '{name_contains}' or fullText contains '{full_text_contains}')"
|
|
639
|
+
)
|
|
640
|
+
query.append(keyword_query)
|
|
641
|
+
|
|
642
|
+
if document_not_contains:
|
|
643
|
+
for keyword in document_not_contains:
|
|
644
|
+
name_not_contains = keyword.replace("'", "\\'")
|
|
645
|
+
full_text_not_contains = keyword.replace("'", "\\'")
|
|
646
|
+
keyword_query = (
|
|
647
|
+
f"(name not contains '{name_not_contains}' and "
|
|
648
|
+
f"fullText not contains '{full_text_not_contains}')"
|
|
649
|
+
)
|
|
650
|
+
query.append(keyword_query)
|
|
651
|
+
|
|
652
|
+
return " and ".join(query)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def build_files_list_params(
|
|
656
|
+
mime_type: str,
|
|
657
|
+
page_size: int,
|
|
658
|
+
order_by: list[OrderBy],
|
|
659
|
+
pagination_token: str | None,
|
|
660
|
+
include_shared_drives: bool,
|
|
661
|
+
search_only_in_shared_drive_id: str | None,
|
|
662
|
+
include_organization_domain_documents: bool,
|
|
663
|
+
document_contains: list[str] | None = None,
|
|
664
|
+
document_not_contains: list[str] | None = None,
|
|
665
|
+
) -> dict[str, Any]:
|
|
666
|
+
query = build_files_list_query(
|
|
667
|
+
mime_type=mime_type,
|
|
668
|
+
document_contains=document_contains,
|
|
669
|
+
document_not_contains=document_not_contains,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
params = {
|
|
673
|
+
"q": query,
|
|
674
|
+
"pageSize": page_size,
|
|
675
|
+
"orderBy": ",".join([item.value for item in order_by]),
|
|
676
|
+
"pageToken": pagination_token,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (
|
|
680
|
+
include_shared_drives
|
|
681
|
+
or search_only_in_shared_drive_id
|
|
682
|
+
or include_organization_domain_documents
|
|
683
|
+
):
|
|
684
|
+
params["includeItemsFromAllDrives"] = "true"
|
|
685
|
+
params["supportsAllDrives"] = "true"
|
|
686
|
+
|
|
687
|
+
if search_only_in_shared_drive_id:
|
|
688
|
+
params["driveId"] = search_only_in_shared_drive_id
|
|
689
|
+
params["corpora"] = Corpora.DRIVE.value
|
|
690
|
+
|
|
691
|
+
if include_organization_domain_documents:
|
|
692
|
+
params["corpora"] = Corpora.DOMAIN.value
|
|
693
|
+
|
|
694
|
+
params = remove_none_values(params)
|
|
695
|
+
|
|
696
|
+
return params
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def build_file_tree_request_params(
|
|
700
|
+
order_by: list[OrderBy] | None,
|
|
701
|
+
page_token: str | None,
|
|
702
|
+
limit: int | None,
|
|
703
|
+
include_shared_drives: bool,
|
|
704
|
+
restrict_to_shared_drive_id: str | None,
|
|
705
|
+
include_organization_domain_documents: bool,
|
|
706
|
+
) -> dict[str, Any]:
|
|
707
|
+
if order_by is None:
|
|
708
|
+
order_by = [OrderBy.MODIFIED_TIME_DESC]
|
|
709
|
+
elif isinstance(order_by, OrderBy):
|
|
710
|
+
order_by = [order_by]
|
|
711
|
+
|
|
712
|
+
params = {
|
|
713
|
+
"q": "trashed = false",
|
|
714
|
+
"corpora": Corpora.USER.value,
|
|
715
|
+
"pageToken": page_token,
|
|
716
|
+
"fields": (
|
|
717
|
+
"files(id, name, parents, mimeType, driveId, size, createdTime, modifiedTime, owners)"
|
|
718
|
+
),
|
|
719
|
+
"orderBy": ",".join([item.value for item in order_by]),
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if limit:
|
|
723
|
+
params["pageSize"] = str(limit)
|
|
724
|
+
|
|
725
|
+
if (
|
|
726
|
+
include_shared_drives
|
|
727
|
+
or restrict_to_shared_drive_id
|
|
728
|
+
or include_organization_domain_documents
|
|
729
|
+
):
|
|
730
|
+
params["includeItemsFromAllDrives"] = "true"
|
|
731
|
+
params["supportsAllDrives"] = "true"
|
|
732
|
+
|
|
733
|
+
if restrict_to_shared_drive_id:
|
|
734
|
+
params["driveId"] = restrict_to_shared_drive_id
|
|
735
|
+
params["corpora"] = Corpora.DRIVE.value
|
|
736
|
+
|
|
737
|
+
if include_organization_domain_documents:
|
|
738
|
+
params["corpora"] = Corpora.DOMAIN.value
|
|
739
|
+
|
|
740
|
+
return params
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def build_file_tree(files: dict[str, Any]) -> dict[str, Any]:
|
|
744
|
+
file_tree: dict[str, Any] = {}
|
|
745
|
+
|
|
746
|
+
for file in files.values():
|
|
747
|
+
owners = file.get("owners", [])
|
|
748
|
+
if owners:
|
|
749
|
+
owners = [
|
|
750
|
+
{"name": owner.get("displayName", ""), "email": owner.get("emailAddress", "")}
|
|
751
|
+
for owner in owners
|
|
752
|
+
]
|
|
753
|
+
file["owners"] = owners
|
|
754
|
+
|
|
755
|
+
if "size" in file:
|
|
756
|
+
file["size"] = {"value": int(file["size"]), "unit": "bytes"}
|
|
757
|
+
|
|
758
|
+
# Although "parents" is a list, a file can only have one parent
|
|
759
|
+
try:
|
|
760
|
+
parent_id = file["parents"][0]
|
|
761
|
+
del file["parents"]
|
|
762
|
+
except (KeyError, IndexError):
|
|
763
|
+
parent_id = None
|
|
764
|
+
|
|
765
|
+
# Determine the file's Drive ID
|
|
766
|
+
if "driveId" in file:
|
|
767
|
+
drive_id = file["driveId"]
|
|
768
|
+
del file["driveId"]
|
|
769
|
+
# If a shared drive id is not present, the file is in "My Drive"
|
|
770
|
+
else:
|
|
771
|
+
drive_id = "My Drive"
|
|
772
|
+
|
|
773
|
+
if drive_id not in file_tree:
|
|
774
|
+
file_tree[drive_id] = []
|
|
775
|
+
|
|
776
|
+
# Root files will have the Drive's id as the parent. If the parent id is not in the files
|
|
777
|
+
# list, the file must be at drive's root
|
|
778
|
+
if parent_id not in files:
|
|
779
|
+
file_tree[drive_id].append(file)
|
|
780
|
+
|
|
781
|
+
# Associate the file with its parent
|
|
782
|
+
else:
|
|
783
|
+
if "children" not in files[parent_id]:
|
|
784
|
+
files[parent_id]["children"] = []
|
|
785
|
+
files[parent_id]["children"].append(file)
|
|
786
|
+
|
|
787
|
+
return file_tree
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# Docs utils
|
|
791
|
+
def build_docs_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
792
|
+
"""
|
|
793
|
+
Build a Drive service object.
|
|
794
|
+
"""
|
|
795
|
+
auth_token = auth_token or ""
|
|
796
|
+
return build("docs", "v1", credentials=Credentials(auth_token))
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def parse_rfc3339_datetime_str(dt_str: str, tz: timezone = timezone.utc) -> datetime:
|
|
800
|
+
"""
|
|
801
|
+
Parse an RFC3339 datetime string into a timezone-aware datetime.
|
|
802
|
+
Converts a trailing 'Z' (UTC) into +00:00.
|
|
803
|
+
If the parsed datetime is naive, assume it is in the provided timezone.
|
|
804
|
+
"""
|
|
805
|
+
if dt_str.endswith("Z"):
|
|
806
|
+
dt_str = dt_str[:-1] + "+00:00"
|
|
807
|
+
dt = datetime.fromisoformat(dt_str)
|
|
808
|
+
if dt.tzinfo is None:
|
|
809
|
+
dt = dt.replace(tzinfo=tz)
|
|
810
|
+
return dt
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]:
|
|
814
|
+
"""
|
|
815
|
+
Given a list of (start, end) tuples, merge overlapping or adjacent intervals.
|
|
816
|
+
"""
|
|
817
|
+
merged: list[tuple[datetime, datetime]] = []
|
|
818
|
+
for start, end in sorted(intervals, key=lambda x: x[0]):
|
|
819
|
+
if not merged:
|
|
820
|
+
merged.append((start, end))
|
|
821
|
+
else:
|
|
822
|
+
last_start, last_end = merged[-1]
|
|
823
|
+
if start <= last_end:
|
|
824
|
+
merged[-1] = (last_start, max(last_end, end))
|
|
825
|
+
else:
|
|
826
|
+
merged.append((start, end))
|
|
827
|
+
return merged
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# Calendar utils
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def build_oauth_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
834
|
+
"""
|
|
835
|
+
Build an OAuth2 service object.
|
|
836
|
+
"""
|
|
837
|
+
auth_token = auth_token or ""
|
|
838
|
+
return build("oauth2", "v2", credentials=Credentials(auth_token))
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def build_calendar_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
842
|
+
"""
|
|
843
|
+
Build a Calendar service object.
|
|
844
|
+
"""
|
|
845
|
+
auth_token = auth_token or ""
|
|
846
|
+
return build("calendar", "v3", credentials=Credentials(auth_token))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def weekday_to_name(weekday: int) -> str:
|
|
850
|
+
return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday]
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def get_time_boundaries_for_date(
|
|
854
|
+
current_date: date,
|
|
855
|
+
global_start: datetime,
|
|
856
|
+
global_end: datetime,
|
|
857
|
+
start_time_boundary: time,
|
|
858
|
+
end_time_boundary: time,
|
|
859
|
+
tz: ZoneInfo,
|
|
860
|
+
) -> tuple[datetime, datetime]:
|
|
861
|
+
"""Compute the allowed start and end times for the given day, adjusting for global bounds."""
|
|
862
|
+
day_start_time = datetime.combine(current_date, start_time_boundary).replace(tzinfo=tz)
|
|
863
|
+
day_end_time = datetime.combine(current_date, end_time_boundary).replace(tzinfo=tz)
|
|
864
|
+
|
|
865
|
+
if current_date == global_start.date():
|
|
866
|
+
day_start_time = max(day_start_time, global_start)
|
|
867
|
+
|
|
868
|
+
if current_date == global_end.date():
|
|
869
|
+
day_end_time = min(day_end_time, global_end)
|
|
870
|
+
|
|
871
|
+
return day_start_time, day_end_time
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def gather_busy_intervals(
|
|
875
|
+
busy_data: dict[str, Any],
|
|
876
|
+
day_start: datetime,
|
|
877
|
+
day_end: datetime,
|
|
878
|
+
business_tz: ZoneInfo,
|
|
879
|
+
) -> list[tuple[datetime, datetime]]:
|
|
880
|
+
"""
|
|
881
|
+
Collect busy intervals from all calendars that intersect with the day's business hours.
|
|
882
|
+
Busy intervals are clipped to lie within [day_start, day_end].
|
|
883
|
+
"""
|
|
884
|
+
busy_intervals = []
|
|
885
|
+
for calendar in busy_data:
|
|
886
|
+
for slot in busy_data[calendar].get("busy", []):
|
|
887
|
+
slot_start = parse_rfc3339_datetime_str(slot["start"]).astimezone(business_tz)
|
|
888
|
+
slot_end = parse_rfc3339_datetime_str(slot["end"]).astimezone(business_tz)
|
|
889
|
+
if slot_end > day_start and slot_start < day_end:
|
|
890
|
+
busy_intervals.append((max(slot_start, day_start), min(slot_end, day_end)))
|
|
891
|
+
return busy_intervals
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def subtract_busy_intervals(
|
|
895
|
+
business_start: datetime,
|
|
896
|
+
business_end: datetime,
|
|
897
|
+
busy_intervals: list[tuple[datetime, datetime]],
|
|
898
|
+
) -> list[dict[str, Any]]:
|
|
899
|
+
"""
|
|
900
|
+
Subtract the merged busy intervals from the business hours and return free time slots.
|
|
901
|
+
"""
|
|
902
|
+
free_slots = []
|
|
903
|
+
merged_busy = merge_intervals(busy_intervals)
|
|
904
|
+
|
|
905
|
+
# If there are no busy intervals, return the entire business window as free.
|
|
906
|
+
if not merged_busy:
|
|
907
|
+
return [
|
|
908
|
+
{
|
|
909
|
+
"start": {
|
|
910
|
+
"datetime": business_start.isoformat(),
|
|
911
|
+
"weekday": weekday_to_name(business_start.weekday()),
|
|
912
|
+
},
|
|
913
|
+
"end": {
|
|
914
|
+
"datetime": business_end.isoformat(),
|
|
915
|
+
"weekday": weekday_to_name(business_end.weekday()),
|
|
916
|
+
},
|
|
917
|
+
}
|
|
918
|
+
]
|
|
919
|
+
|
|
920
|
+
current_free_start = business_start
|
|
921
|
+
for busy_start, busy_end in merged_busy:
|
|
922
|
+
if current_free_start < busy_start:
|
|
923
|
+
free_slots.append({
|
|
924
|
+
"start": {
|
|
925
|
+
"datetime": current_free_start.isoformat(),
|
|
926
|
+
"weekday": weekday_to_name(current_free_start.weekday()),
|
|
927
|
+
},
|
|
928
|
+
"end": {
|
|
929
|
+
"datetime": busy_start.isoformat(),
|
|
930
|
+
"weekday": weekday_to_name(busy_start.weekday()),
|
|
931
|
+
},
|
|
932
|
+
})
|
|
933
|
+
current_free_start = max(current_free_start, busy_end)
|
|
934
|
+
if current_free_start < business_end:
|
|
935
|
+
free_slots.append({
|
|
936
|
+
"start": {
|
|
937
|
+
"datetime": current_free_start.isoformat(),
|
|
938
|
+
"weekday": weekday_to_name(current_free_start.weekday()),
|
|
939
|
+
},
|
|
940
|
+
"end": {
|
|
941
|
+
"datetime": business_end.isoformat(),
|
|
942
|
+
"weekday": weekday_to_name(business_end.weekday()),
|
|
943
|
+
},
|
|
944
|
+
})
|
|
945
|
+
return free_slots
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def compute_free_time_intersection(
|
|
949
|
+
busy_data: dict[str, Any],
|
|
950
|
+
global_start: datetime,
|
|
951
|
+
global_end: datetime,
|
|
952
|
+
start_time_boundary: time,
|
|
953
|
+
end_time_boundary: time,
|
|
954
|
+
include_weekends: bool,
|
|
955
|
+
tz: ZoneInfo,
|
|
956
|
+
) -> list[dict[str, Any]]:
|
|
957
|
+
"""
|
|
958
|
+
Returns the free time slots across all calendars within the global bounds,
|
|
959
|
+
ensuring that the global start is not in the past.
|
|
960
|
+
|
|
961
|
+
Only considers business days (Monday to Friday) and business hours (08:00-19:00)
|
|
962
|
+
in the provided timezone.
|
|
963
|
+
"""
|
|
964
|
+
# Ensure global_start is never in the past relative to now.
|
|
965
|
+
now = get_now(tz)
|
|
966
|
+
|
|
967
|
+
if now > global_start:
|
|
968
|
+
global_start = now
|
|
969
|
+
|
|
970
|
+
# If after adjusting the start, there's no interval left, return empty.
|
|
971
|
+
if global_start >= global_end:
|
|
972
|
+
return []
|
|
973
|
+
|
|
974
|
+
free_slots = []
|
|
975
|
+
current_date = global_start.date()
|
|
976
|
+
|
|
977
|
+
while current_date <= global_end.date():
|
|
978
|
+
if not include_weekends and current_date.weekday() >= 5:
|
|
979
|
+
current_date += timedelta(days=1)
|
|
980
|
+
continue
|
|
981
|
+
|
|
982
|
+
day_start, day_end = get_time_boundaries_for_date(
|
|
983
|
+
current_date=current_date,
|
|
984
|
+
global_start=global_start,
|
|
985
|
+
global_end=global_end,
|
|
986
|
+
start_time_boundary=start_time_boundary,
|
|
987
|
+
end_time_boundary=end_time_boundary,
|
|
988
|
+
tz=tz,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
# Skip if the day's allowed time window is empty.
|
|
992
|
+
if day_start >= day_end:
|
|
993
|
+
current_date += timedelta(days=1)
|
|
994
|
+
continue
|
|
995
|
+
|
|
996
|
+
busy_intervals = gather_busy_intervals(busy_data, day_start, day_end, tz)
|
|
997
|
+
free_slots.extend(subtract_busy_intervals(day_start, day_end, busy_intervals))
|
|
998
|
+
|
|
999
|
+
current_date += timedelta(days=1)
|
|
1000
|
+
|
|
1001
|
+
return free_slots
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def get_now(tz: ZoneInfo | None = None) -> datetime:
|
|
1005
|
+
if not tz:
|
|
1006
|
+
tz = ZoneInfo("UTC")
|
|
1007
|
+
return datetime.now(tz)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
# Contacts utils
|
|
1011
|
+
def build_people_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
1012
|
+
"""
|
|
1013
|
+
Build a People service object.
|
|
1014
|
+
"""
|
|
1015
|
+
auth_token = auth_token or ""
|
|
1016
|
+
return build("people", "v1", credentials=Credentials(auth_token))
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def search_contacts(service: Any, query: str, limit: int | None) -> list[dict[str, Any]]:
|
|
1020
|
+
"""
|
|
1021
|
+
Search the user's contacts in Google Contacts.
|
|
1022
|
+
"""
|
|
1023
|
+
response = (
|
|
1024
|
+
service.people()
|
|
1025
|
+
.searchContacts(
|
|
1026
|
+
query=query,
|
|
1027
|
+
pageSize=limit or DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
1028
|
+
readMask=",".join([
|
|
1029
|
+
"names",
|
|
1030
|
+
"nicknames",
|
|
1031
|
+
"emailAddresses",
|
|
1032
|
+
"phoneNumbers",
|
|
1033
|
+
"addresses",
|
|
1034
|
+
"organizations",
|
|
1035
|
+
"biographies",
|
|
1036
|
+
"urls",
|
|
1037
|
+
"userDefined",
|
|
1038
|
+
]),
|
|
1039
|
+
)
|
|
1040
|
+
.execute()
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
return cast(list[dict[str, Any]], response.get("results", []))
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# ----------------------------------------------------------------
|
|
1047
|
+
# Sheets utils
|
|
1048
|
+
# ----------------------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def build_sheets_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
1052
|
+
"""
|
|
1053
|
+
Build a Sheets service object.
|
|
1054
|
+
"""
|
|
1055
|
+
auth_token = auth_token or ""
|
|
1056
|
+
return build("sheets", "v4", credentials=Credentials(auth_token))
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def col_to_index(col: str) -> int:
|
|
1060
|
+
"""Convert a sheet's column string to a 0-indexed column index
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
col (str): The column string to convert. e.g., "A", "AZ", "QED"
|
|
1064
|
+
|
|
1065
|
+
Returns:
|
|
1066
|
+
int: The 0-indexed column index.
|
|
1067
|
+
"""
|
|
1068
|
+
result = 0
|
|
1069
|
+
for char in col.upper():
|
|
1070
|
+
result = result * 26 + (ord(char) - ord("A") + 1)
|
|
1071
|
+
return result - 1
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def index_to_col(index: int) -> str:
|
|
1075
|
+
"""Convert a 0-indexed column index to its corresponding column string
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
index (int): The 0-indexed column index to convert.
|
|
1079
|
+
|
|
1080
|
+
Returns:
|
|
1081
|
+
str: The column string. e.g., "A", "AZ", "QED"
|
|
1082
|
+
"""
|
|
1083
|
+
result = ""
|
|
1084
|
+
index += 1
|
|
1085
|
+
while index > 0:
|
|
1086
|
+
index, rem = divmod(index - 1, 26)
|
|
1087
|
+
result = chr(rem + ord("A")) + result
|
|
1088
|
+
return result
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def is_col_greater(col1: str, col2: str) -> bool:
|
|
1092
|
+
"""Determine if col1 represents a column that comes after col2 in a sheet
|
|
1093
|
+
|
|
1094
|
+
This comparison is based on:
|
|
1095
|
+
1. The length of the column string (longer means greater).
|
|
1096
|
+
2. Lexicographical comparison if both strings are the same length.
|
|
1097
|
+
|
|
1098
|
+
Args:
|
|
1099
|
+
col1 (str): The first column string to compare.
|
|
1100
|
+
col2 (str): The second column string to compare.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
bool: True if col1 comes after col2, False otherwise.
|
|
1104
|
+
"""
|
|
1105
|
+
if len(col1) != len(col2):
|
|
1106
|
+
return len(col1) > len(col2)
|
|
1107
|
+
return col1.upper() > col2.upper()
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def compute_sheet_data_dimensions(
|
|
1111
|
+
sheet_data_input: SheetDataInput,
|
|
1112
|
+
) -> tuple[tuple[int, int], tuple[int, int]]:
|
|
1113
|
+
"""
|
|
1114
|
+
Compute the dimensions of a sheet based on the data provided.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
sheet_data_input (SheetDataInput):
|
|
1118
|
+
The data to compute the dimensions of.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
tuple[tuple[int, int], tuple[int, int]]: The dimensions of the sheet. The first tuple
|
|
1122
|
+
contains the row range (start, end) and the second tuple contains the column range
|
|
1123
|
+
(start, end).
|
|
1124
|
+
"""
|
|
1125
|
+
max_row = 0
|
|
1126
|
+
min_row = 10_000_000 # max number of cells in a sheet
|
|
1127
|
+
max_col_str = None
|
|
1128
|
+
min_col_str = None
|
|
1129
|
+
|
|
1130
|
+
for key, row in sheet_data_input.data.items():
|
|
1131
|
+
try:
|
|
1132
|
+
row_num = int(key)
|
|
1133
|
+
except ValueError:
|
|
1134
|
+
continue
|
|
1135
|
+
if row_num > max_row:
|
|
1136
|
+
max_row = row_num
|
|
1137
|
+
if row_num < min_row:
|
|
1138
|
+
min_row = row_num
|
|
1139
|
+
|
|
1140
|
+
if isinstance(row, dict):
|
|
1141
|
+
for col in row:
|
|
1142
|
+
# Update max column string
|
|
1143
|
+
if max_col_str is None or is_col_greater(col, max_col_str):
|
|
1144
|
+
max_col_str = col
|
|
1145
|
+
# Update min column string
|
|
1146
|
+
if min_col_str is None or is_col_greater(min_col_str, col):
|
|
1147
|
+
min_col_str = col
|
|
1148
|
+
|
|
1149
|
+
max_col_index = col_to_index(max_col_str) if max_col_str is not None else -1
|
|
1150
|
+
min_col_index = col_to_index(min_col_str) if min_col_str is not None else 0
|
|
1151
|
+
|
|
1152
|
+
return (min_row, max_row), (min_col_index, max_col_index)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def create_sheet(sheet_data_input: SheetDataInput) -> Sheet:
|
|
1156
|
+
"""Create a Google Sheet from a dictionary of data.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
sheet_data_input (SheetDataInput): The data to create the sheet from.
|
|
1160
|
+
|
|
1161
|
+
Returns:
|
|
1162
|
+
Sheet: The created sheet.
|
|
1163
|
+
"""
|
|
1164
|
+
(_, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(sheet_data_input)
|
|
1165
|
+
sheet_data = create_sheet_data(sheet_data_input, min_col_index, max_col_index)
|
|
1166
|
+
sheet_properties = create_sheet_properties(
|
|
1167
|
+
row_count=max(DEFAULT_SHEET_ROW_COUNT, max_row),
|
|
1168
|
+
column_count=max(DEFAULT_SHEET_COLUMN_COUNT, max_col_index + 1),
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
return Sheet(properties=sheet_properties, data=sheet_data)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def create_sheet_properties(
|
|
1175
|
+
sheet_id: int = 1,
|
|
1176
|
+
title: str = "Sheet1",
|
|
1177
|
+
row_count: int = DEFAULT_SHEET_ROW_COUNT,
|
|
1178
|
+
column_count: int = DEFAULT_SHEET_COLUMN_COUNT,
|
|
1179
|
+
) -> SheetProperties:
|
|
1180
|
+
"""Create a SheetProperties object
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
sheet_id (int): The ID of the sheet.
|
|
1184
|
+
title (str): The title of the sheet.
|
|
1185
|
+
row_count (int): The number of rows in the sheet.
|
|
1186
|
+
column_count (int): The number of columns in the sheet.
|
|
1187
|
+
|
|
1188
|
+
Returns:
|
|
1189
|
+
SheetProperties: The created sheet properties object.
|
|
1190
|
+
"""
|
|
1191
|
+
return SheetProperties(
|
|
1192
|
+
sheetId=sheet_id,
|
|
1193
|
+
title=title,
|
|
1194
|
+
gridProperties=GridProperties(rowCount=row_count, columnCount=column_count),
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def group_contiguous_rows(row_numbers: list[int]) -> list[list[int]]:
|
|
1199
|
+
"""Groups a sorted list of row numbers into contiguous groups
|
|
1200
|
+
|
|
1201
|
+
A contiguous group is a list of row numbers that are consecutive integers.
|
|
1202
|
+
For example, [1,2,3,5,6] is converted to [[1,2,3],[5,6]].
|
|
1203
|
+
|
|
1204
|
+
Args:
|
|
1205
|
+
row_numbers (list[int]): The list of row numbers to group.
|
|
1206
|
+
|
|
1207
|
+
Returns:
|
|
1208
|
+
list[list[int]]: The grouped row numbers.
|
|
1209
|
+
"""
|
|
1210
|
+
if not row_numbers:
|
|
1211
|
+
return []
|
|
1212
|
+
groups = []
|
|
1213
|
+
current_group = [row_numbers[0]]
|
|
1214
|
+
for r in row_numbers[1:]:
|
|
1215
|
+
if r == current_group[-1] + 1:
|
|
1216
|
+
current_group.append(r)
|
|
1217
|
+
else:
|
|
1218
|
+
groups.append(current_group)
|
|
1219
|
+
current_group = [r]
|
|
1220
|
+
groups.append(current_group)
|
|
1221
|
+
return groups
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def create_cell_data(cell_value: CellValue) -> CellData:
|
|
1225
|
+
"""
|
|
1226
|
+
Create a CellData object based on the type of cell_value.
|
|
1227
|
+
"""
|
|
1228
|
+
if isinstance(cell_value, bool):
|
|
1229
|
+
return _create_bool_cell(cell_value)
|
|
1230
|
+
elif isinstance(cell_value, int):
|
|
1231
|
+
return _create_int_cell(cell_value)
|
|
1232
|
+
elif isinstance(cell_value, float):
|
|
1233
|
+
return _create_float_cell(cell_value)
|
|
1234
|
+
elif isinstance(cell_value, str):
|
|
1235
|
+
return _create_string_cell(cell_value)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _create_formula_cell(cell_value: str) -> CellData:
|
|
1239
|
+
cell_val = CellExtendedValue(formulaValue=cell_value)
|
|
1240
|
+
return CellData(userEnteredValue=cell_val)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _create_currency_cell(cell_value: str) -> CellData:
|
|
1244
|
+
value_without_symbol = cell_value[1:]
|
|
1245
|
+
try:
|
|
1246
|
+
num_value = int(value_without_symbol)
|
|
1247
|
+
cell_format = CellFormat(
|
|
1248
|
+
numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0")
|
|
1249
|
+
)
|
|
1250
|
+
cell_val = CellExtendedValue(numberValue=num_value)
|
|
1251
|
+
return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
|
|
1252
|
+
except ValueError:
|
|
1253
|
+
try:
|
|
1254
|
+
num_value = float(value_without_symbol) # type: ignore[assignment]
|
|
1255
|
+
cell_format = CellFormat(
|
|
1256
|
+
numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0.00")
|
|
1257
|
+
)
|
|
1258
|
+
cell_val = CellExtendedValue(numberValue=num_value)
|
|
1259
|
+
return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
|
|
1260
|
+
except ValueError:
|
|
1261
|
+
return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _create_percent_cell(cell_value: str) -> CellData:
|
|
1265
|
+
try:
|
|
1266
|
+
num_value = float(cell_value[:-1].strip())
|
|
1267
|
+
cell_format = CellFormat(
|
|
1268
|
+
numberFormat=NumberFormat(type=NumberFormatType.PERCENT, pattern="0.00%")
|
|
1269
|
+
)
|
|
1270
|
+
cell_val = CellExtendedValue(numberValue=num_value)
|
|
1271
|
+
return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
|
|
1272
|
+
except ValueError:
|
|
1273
|
+
return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _create_bool_cell(cell_value: bool) -> CellData:
|
|
1277
|
+
return CellData(userEnteredValue=CellExtendedValue(boolValue=cell_value))
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _create_int_cell(cell_value: int) -> CellData:
|
|
1281
|
+
cell_format = CellFormat(
|
|
1282
|
+
numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0")
|
|
1283
|
+
)
|
|
1284
|
+
return CellData(
|
|
1285
|
+
userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _create_float_cell(cell_value: float) -> CellData:
|
|
1290
|
+
cell_format = CellFormat(
|
|
1291
|
+
numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0.00")
|
|
1292
|
+
)
|
|
1293
|
+
return CellData(
|
|
1294
|
+
userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _create_string_cell(cell_value: str) -> CellData:
|
|
1299
|
+
if cell_value.startswith("="):
|
|
1300
|
+
return _create_formula_cell(cell_value)
|
|
1301
|
+
elif cell_value.startswith("$") and len(cell_value) > 1:
|
|
1302
|
+
return _create_currency_cell(cell_value)
|
|
1303
|
+
elif cell_value.endswith("%") and len(cell_value) > 1:
|
|
1304
|
+
return _create_percent_cell(cell_value)
|
|
1305
|
+
|
|
1306
|
+
return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def create_row_data(
|
|
1310
|
+
row_data: dict[str, CellValue], min_col_index: int, max_col_index: int
|
|
1311
|
+
) -> RowData:
|
|
1312
|
+
"""Constructs RowData for a single row using the provided row_data.
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
row_data (dict[str, CellValue]): The data to create the row from.
|
|
1316
|
+
min_col_index (int): The minimum column index from the SheetDataInput.
|
|
1317
|
+
max_col_index (int): The maximum column index from the SheetDataInput.
|
|
1318
|
+
"""
|
|
1319
|
+
row_cells = []
|
|
1320
|
+
for col_idx in range(min_col_index, max_col_index + 1):
|
|
1321
|
+
col_letter = index_to_col(col_idx)
|
|
1322
|
+
if col_letter in row_data:
|
|
1323
|
+
cell_data = create_cell_data(row_data[col_letter])
|
|
1324
|
+
else:
|
|
1325
|
+
cell_data = CellData(userEnteredValue=CellExtendedValue(stringValue=""))
|
|
1326
|
+
row_cells.append(cell_data)
|
|
1327
|
+
return RowData(values=row_cells)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def create_sheet_data(
|
|
1331
|
+
sheet_data_input: SheetDataInput,
|
|
1332
|
+
min_col_index: int,
|
|
1333
|
+
max_col_index: int,
|
|
1334
|
+
) -> list[GridData]:
|
|
1335
|
+
"""Create grid data from SheetDataInput by grouping contiguous rows and processing cells.
|
|
1336
|
+
|
|
1337
|
+
Args:
|
|
1338
|
+
sheet_data_input (SheetDataInput): The data to create the sheet from.
|
|
1339
|
+
min_col_index (int): The minimum column index from the SheetDataInput.
|
|
1340
|
+
max_col_index (int): The maximum column index from the SheetDataInput.
|
|
1341
|
+
|
|
1342
|
+
Returns:
|
|
1343
|
+
list[GridData]: The created grid data.
|
|
1344
|
+
"""
|
|
1345
|
+
row_numbers = list(sheet_data_input.data.keys())
|
|
1346
|
+
if not row_numbers:
|
|
1347
|
+
return []
|
|
1348
|
+
|
|
1349
|
+
sorted_rows = sorted(row_numbers)
|
|
1350
|
+
groups = group_contiguous_rows(sorted_rows)
|
|
1351
|
+
|
|
1352
|
+
sheet_data = []
|
|
1353
|
+
for group in groups:
|
|
1354
|
+
rows_data = []
|
|
1355
|
+
for r in group:
|
|
1356
|
+
current_row_data = sheet_data_input.data.get(r, {})
|
|
1357
|
+
row = create_row_data(current_row_data, min_col_index, max_col_index)
|
|
1358
|
+
rows_data.append(row)
|
|
1359
|
+
grid_data = GridData(
|
|
1360
|
+
startRow=group[0] - 1, # convert to 0-indexed
|
|
1361
|
+
startColumn=min_col_index,
|
|
1362
|
+
rowData=rows_data,
|
|
1363
|
+
)
|
|
1364
|
+
sheet_data.append(grid_data)
|
|
1365
|
+
|
|
1366
|
+
return sheet_data
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def parse_get_spreadsheet_response(api_response: dict) -> dict:
|
|
1370
|
+
"""
|
|
1371
|
+
Parse the get spreadsheet Google Sheets API response into a structured dictionary.
|
|
1372
|
+
"""
|
|
1373
|
+
properties = api_response.get("properties", {})
|
|
1374
|
+
sheets = [parse_sheet(sheet) for sheet in api_response.get("sheets", [])]
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
"title": properties.get("title", ""),
|
|
1378
|
+
"spreadsheetId": api_response.get("spreadsheetId", ""),
|
|
1379
|
+
"spreadsheetUrl": api_response.get("spreadsheetUrl", ""),
|
|
1380
|
+
"sheets": sheets,
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def parse_sheet(api_sheet: dict) -> dict:
|
|
1385
|
+
"""
|
|
1386
|
+
Parse an individual sheet's data from the Google Sheets 'get spreadsheet'
|
|
1387
|
+
API response into a structured dictionary.
|
|
1388
|
+
"""
|
|
1389
|
+
props = api_sheet.get("properties", {})
|
|
1390
|
+
grid_props = props.get("gridProperties", {})
|
|
1391
|
+
cell_data = convert_api_grid_data_to_dict(api_sheet.get("data", []))
|
|
1392
|
+
|
|
1393
|
+
return {
|
|
1394
|
+
"sheetId": props.get("sheetId"),
|
|
1395
|
+
"title": props.get("title", ""),
|
|
1396
|
+
"rowCount": grid_props.get("rowCount", 0),
|
|
1397
|
+
"columnCount": grid_props.get("columnCount", 0),
|
|
1398
|
+
"data": cell_data,
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def extract_user_entered_cell_value(cell: dict) -> Any:
|
|
1403
|
+
"""
|
|
1404
|
+
Extract the user entered value from a cell's 'userEnteredValue'.
|
|
1405
|
+
|
|
1406
|
+
Args:
|
|
1407
|
+
cell (dict): A cell dictionary from the grid data.
|
|
1408
|
+
|
|
1409
|
+
Returns:
|
|
1410
|
+
The extracted value if present, otherwise None.
|
|
1411
|
+
"""
|
|
1412
|
+
user_val = cell.get("userEnteredValue", {})
|
|
1413
|
+
for key in ["stringValue", "numberValue", "boolValue", "formulaValue"]:
|
|
1414
|
+
if key in user_val:
|
|
1415
|
+
return user_val[key]
|
|
1416
|
+
|
|
1417
|
+
return ""
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def process_row(row: dict, start_column_index: int) -> dict:
|
|
1421
|
+
"""
|
|
1422
|
+
Process a single row from grid data, converting non-empty cells into a dictionary
|
|
1423
|
+
that maps column letters to cell values.
|
|
1424
|
+
|
|
1425
|
+
Args:
|
|
1426
|
+
row (dict): A row from the grid data.
|
|
1427
|
+
start_column_index (int): The starting column index for this row.
|
|
1428
|
+
|
|
1429
|
+
Returns:
|
|
1430
|
+
dict: A mapping of column letters to cell values for non-empty cells.
|
|
1431
|
+
"""
|
|
1432
|
+
row_result = {}
|
|
1433
|
+
for j, cell in enumerate(row.get("values", [])):
|
|
1434
|
+
column_index = start_column_index + j
|
|
1435
|
+
column_string = index_to_col(column_index)
|
|
1436
|
+
user_entered_cell_value = extract_user_entered_cell_value(cell)
|
|
1437
|
+
formatted_cell_value = cell.get("formattedValue", "")
|
|
1438
|
+
|
|
1439
|
+
if user_entered_cell_value != "" or formatted_cell_value != "":
|
|
1440
|
+
row_result[column_string] = {
|
|
1441
|
+
"userEnteredValue": user_entered_cell_value,
|
|
1442
|
+
"formattedValue": formatted_cell_value,
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return row_result
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def convert_api_grid_data_to_dict(grids: list[dict]) -> dict:
|
|
1449
|
+
"""
|
|
1450
|
+
Convert a list of grid data dictionaries from the 'get spreadsheet' API
|
|
1451
|
+
response into a structured cell dictionary.
|
|
1452
|
+
|
|
1453
|
+
The returned dictionary maps row numbers to sub-dictionaries that map column letters
|
|
1454
|
+
(e.g., 'A', 'B', etc.) to their corresponding non-empty cell values.
|
|
1455
|
+
|
|
1456
|
+
Args:
|
|
1457
|
+
grids (list[dict]): The list of grid data dictionaries from the API.
|
|
1458
|
+
|
|
1459
|
+
Returns:
|
|
1460
|
+
dict: A dictionary mapping row numbers to dictionaries of column letter/value pairs.
|
|
1461
|
+
Only includes non-empty rows and non-empty cells.
|
|
1462
|
+
"""
|
|
1463
|
+
result = {}
|
|
1464
|
+
for grid in grids:
|
|
1465
|
+
start_row = grid.get("startRow", 0)
|
|
1466
|
+
start_column = grid.get("startColumn", 0)
|
|
1467
|
+
|
|
1468
|
+
for i, row in enumerate(grid.get("rowData", []), start=1):
|
|
1469
|
+
current_row = start_row + i
|
|
1470
|
+
row_data = process_row(row, start_column)
|
|
1471
|
+
|
|
1472
|
+
if row_data:
|
|
1473
|
+
result[current_row] = row_data
|
|
1474
|
+
|
|
1475
|
+
return dict(sorted(result.items()))
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def validate_write_to_cell_params( # type: ignore[no-any-unimported]
|
|
1479
|
+
service: Resource,
|
|
1480
|
+
spreadsheet_id: str,
|
|
1481
|
+
sheet_name: str,
|
|
1482
|
+
column: str,
|
|
1483
|
+
row: int,
|
|
1484
|
+
) -> None:
|
|
1485
|
+
"""Validates the input parameters for the write to cell tool.
|
|
1486
|
+
|
|
1487
|
+
Args:
|
|
1488
|
+
service (Resource): The Google Sheets service.
|
|
1489
|
+
spreadsheet_id (str): The ID of the spreadsheet provided to the tool.
|
|
1490
|
+
sheet_name (str): The name of the sheet provided to the tool.
|
|
1491
|
+
column (str): The column to write to provided to the tool.
|
|
1492
|
+
row (int): The row to write to provided to the tool.
|
|
1493
|
+
|
|
1494
|
+
Raises:
|
|
1495
|
+
RetryableToolError:
|
|
1496
|
+
If the sheet name is not found in the spreadsheet
|
|
1497
|
+
ToolExecutionError:
|
|
1498
|
+
If the column is not alphabetical
|
|
1499
|
+
If the row is not a positive number
|
|
1500
|
+
If the row is out of bounds for the sheet
|
|
1501
|
+
If the column is out of bounds for the sheet
|
|
1502
|
+
"""
|
|
1503
|
+
if not column.isalpha():
|
|
1504
|
+
raise ToolExecutionError(
|
|
1505
|
+
message=(
|
|
1506
|
+
f"Invalid column name {column}. "
|
|
1507
|
+
"It must be a non-empty string containing only letters"
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
if row < 1:
|
|
1512
|
+
raise ToolExecutionError(
|
|
1513
|
+
message=(f"Invalid row number {row}. It must be a positive integer greater than 0."),
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
sheet_properties = (
|
|
1517
|
+
service.spreadsheets()
|
|
1518
|
+
.get(
|
|
1519
|
+
spreadsheetId=spreadsheet_id,
|
|
1520
|
+
includeGridData=True,
|
|
1521
|
+
fields="sheets/properties/title,sheets/properties/gridProperties/rowCount,sheets/properties/gridProperties/columnCount",
|
|
1522
|
+
)
|
|
1523
|
+
.execute()
|
|
1524
|
+
)
|
|
1525
|
+
sheet_names = [sheet["properties"]["title"] for sheet in sheet_properties["sheets"]]
|
|
1526
|
+
sheet_row_count = sheet_properties["sheets"][0]["properties"]["gridProperties"]["rowCount"]
|
|
1527
|
+
sheet_column_count = sheet_properties["sheets"][0]["properties"]["gridProperties"][
|
|
1528
|
+
"columnCount"
|
|
1529
|
+
]
|
|
1530
|
+
|
|
1531
|
+
if sheet_name not in sheet_names:
|
|
1532
|
+
raise RetryableToolError(
|
|
1533
|
+
message=f"Sheet name {sheet_name} not found in spreadsheet with id {spreadsheet_id}",
|
|
1534
|
+
additional_prompt_content=f"Sheet names in the spreadsheet: {sheet_names}",
|
|
1535
|
+
retry_after_ms=100,
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
if row > sheet_row_count:
|
|
1539
|
+
raise ToolExecutionError(
|
|
1540
|
+
message=(
|
|
1541
|
+
f"Row {row} is out of bounds for sheet {sheet_name} "
|
|
1542
|
+
f"in spreadsheet with id {spreadsheet_id}. "
|
|
1543
|
+
f"Sheet only has {sheet_row_count} rows which is less than the requested row {row}"
|
|
1544
|
+
)
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
if col_to_index(column) > sheet_column_count:
|
|
1548
|
+
raise ToolExecutionError(
|
|
1549
|
+
message=(
|
|
1550
|
+
f"Column {column} is out of bounds for sheet {sheet_name} "
|
|
1551
|
+
f"in spreadsheet with id {spreadsheet_id}. "
|
|
1552
|
+
f"Sheet only has {sheet_column_count} columns which "
|
|
1553
|
+
f"is less than the requested column {column}"
|
|
1554
|
+
)
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def parse_write_to_cell_response(response: dict) -> dict:
|
|
1559
|
+
return {
|
|
1560
|
+
"spreadsheetId": response["spreadsheetId"],
|
|
1561
|
+
"sheetTitle": response["updatedData"]["range"].split("!")[0],
|
|
1562
|
+
"updatedCell": response["updatedData"]["range"].split("!")[1],
|
|
1563
|
+
"value": response["updatedData"]["values"][0][0],
|
|
1564
|
+
}
|