google-workspace-mcp 1.1.5__py3-none-any.whl → 1.2.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.
@@ -29,20 +29,128 @@ class DriveService(BaseGoogleService):
29
29
 
30
30
  def _escape_drive_query(self, query: str) -> str:
31
31
  """
32
- Basic query cleaning for Drive API queries.
32
+ Smart query processing for Drive API queries.
33
+
34
+ Automatically converts simple text searches to proper Drive API format:
35
+ - Simple text like "sprint planning" becomes fullText contains 'sprint planning'
36
+ - Already formatted queries like "name contains 'test'" are passed through
37
+ - Handles apostrophe escaping automatically
38
+ - Validates queries to prevent invalid syntax
33
39
 
34
40
  Args:
35
- query: Query string (users should manually escape apostrophes with \')
41
+ query: Query string (can be simple text or Drive API format)
36
42
 
37
43
  Returns:
38
- Cleaned query string for Drive API
44
+ Properly formatted query string for Drive API
45
+
46
+ Raises:
47
+ ValueError: If query contains invalid syntax like unsupported parentheses
39
48
  """
40
- # Simply remove surrounding double quotes if present
41
- # Users should handle apostrophe escaping manually (e.g., John\'s Documents)
49
+ if not query or not query.strip():
50
+ return ""
51
+
52
+ query = query.strip()
53
+
54
+ # Remove surrounding double quotes if present
42
55
  if query.startswith('"') and query.endswith('"'):
43
56
  query = query[1:-1]
44
57
 
45
- return query
58
+ # Check for unsupported syntax - parentheses are not supported in files.list queries
59
+ if "(" in query or ")" in query:
60
+ raise ValueError(
61
+ "Parentheses are not supported in Drive file search queries. "
62
+ "Use separate search calls or restructure your query. "
63
+ "For complex searches, try: fullText contains 'term1' OR fullText contains 'term2'"
64
+ )
65
+
66
+ # Check if this is already a structured Drive API query
67
+ # Look for Drive API operators and syntax
68
+ drive_api_indicators = [
69
+ " contains ",
70
+ " = ",
71
+ " != ",
72
+ " > ",
73
+ " < ",
74
+ " >= ",
75
+ " <= ",
76
+ " in ",
77
+ " and ",
78
+ " or ",
79
+ " not ",
80
+ "mimeType",
81
+ "parents",
82
+ "trashed",
83
+ "sharedWithMe",
84
+ "starred",
85
+ "modifiedTime",
86
+ "createdTime",
87
+ "name",
88
+ "fullText",
89
+ ]
90
+
91
+ is_structured_query = any(
92
+ indicator in query.lower() for indicator in drive_api_indicators
93
+ )
94
+
95
+ if is_structured_query:
96
+ # Validate that it's not a mixed query (text + operators + API syntax)
97
+ # Mixed queries like "ArcLio (sprint OR planning) modifiedTime > '2024-01-01'" are invalid
98
+ words = query.split()
99
+ has_unquoted_text = False
100
+
101
+ for word in words:
102
+ # Skip quoted strings, operators, and API field names
103
+ if (
104
+ word.startswith("'")
105
+ or word.endswith("'")
106
+ or word.lower() in ["and", "or", "not", "contains", "in"]
107
+ or any(
108
+ field in word.lower()
109
+ for field in [
110
+ "mimetype",
111
+ "name",
112
+ "fulltext",
113
+ "modifiedtime",
114
+ "createdtime",
115
+ "trashed",
116
+ "starred",
117
+ "sharedwithme",
118
+ "parents",
119
+ ]
120
+ )
121
+ or word in ["=", "!=", ">", "<", ">=", "<="]
122
+ or word.startswith("'")
123
+ and word.endswith("'")
124
+ ):
125
+ continue
126
+
127
+ # Check if this looks like unquoted text mixed with operators
128
+ if not word.replace("-", "").replace("_", "").isalnum():
129
+ continue
130
+
131
+ # If we find unquoted alphanumeric text in a structured query, it might be mixed
132
+ if (
133
+ any(op in query.lower() for op in [" and ", " or "])
134
+ and len(word) > 2
135
+ ):
136
+ has_unquoted_text = True
137
+ break
138
+
139
+ if has_unquoted_text:
140
+ raise ValueError(
141
+ "Mixed query syntax detected. Use either: "
142
+ "1) Simple text: 'sprint planning meeting' "
143
+ "2) Drive API syntax: fullText contains 'sprint' OR fullText contains 'planning' "
144
+ "3) Structured queries: modifiedTime > '2024-01-01' AND name contains 'sprint'"
145
+ )
146
+
147
+ # This looks like a valid structured Drive API query, just escape apostrophes
148
+ return query.replace("'", "\\'")
149
+ # This is a simple text search, convert to fullText search
150
+ # Escape apostrophes in the search term
151
+ escaped_text = query.replace("'", "\\'")
152
+ # Wrap in fullText contains for better search results
153
+ return f"fullText contains '{escaped_text}'"
46
154
 
47
155
  def search_files(
48
156
  self,
@@ -118,7 +226,7 @@ class DriveService(BaseGoogleService):
118
226
  # Get file metadata
119
227
  file_metadata = (
120
228
  self.service.files()
121
- .get(fileId=file_id, fields="mimeType, name")
229
+ .get(fileId=file_id, fields="mimeType, name", supportsAllDrives=True)
122
230
  .execute()
123
231
  )
124
232
 
@@ -261,7 +369,7 @@ class DriveService(BaseGoogleService):
261
369
  # Export the file
262
370
  try:
263
371
  request = self.service.files().export_media(
264
- fileId=file_id, mimeType=export_mime_type, supportsAllDrives=True
372
+ fileId=file_id, mimeType=export_mime_type
265
373
  )
266
374
 
267
375
  content_bytes = self._download_content(request)
@@ -273,21 +381,27 @@ class DriveService(BaseGoogleService):
273
381
  try:
274
382
  content = content_bytes.decode("utf-8")
275
383
  return {
276
- "mimeType": export_mime_type,
384
+ "file_id": file_id,
385
+ "name": file_name,
386
+ "mime_type": export_mime_type,
277
387
  "content": content,
278
388
  "encoding": "utf-8",
279
389
  }
280
390
  except UnicodeDecodeError:
281
391
  content = base64.b64encode(content_bytes).decode("utf-8")
282
392
  return {
283
- "mimeType": export_mime_type,
393
+ "file_id": file_id,
394
+ "name": file_name,
395
+ "mime_type": export_mime_type,
284
396
  "content": content,
285
397
  "encoding": "base64",
286
398
  }
287
399
  else:
288
400
  content = base64.b64encode(content_bytes).decode("utf-8")
289
401
  return {
290
- "mimeType": export_mime_type,
402
+ "file_id": file_id,
403
+ "name": file_name,
404
+ "mime_type": export_mime_type,
291
405
  "content": content,
292
406
  "encoding": "base64",
293
407
  }
@@ -308,21 +422,35 @@ class DriveService(BaseGoogleService):
308
422
  if mime_type.startswith("text/") or mime_type == "application/json":
309
423
  try:
310
424
  content = content_bytes.decode("utf-8")
311
- return {"mimeType": mime_type, "content": content, "encoding": "utf-8"}
425
+ return {
426
+ "file_id": file_id,
427
+ "name": file_name,
428
+ "mime_type": mime_type,
429
+ "content": content,
430
+ "encoding": "utf-8",
431
+ }
312
432
  except UnicodeDecodeError:
313
433
  logger.warning(
314
434
  f"UTF-8 decoding failed for file {file_id} ('{file_name}', {mime_type}). Using base64."
315
435
  )
316
436
  content = base64.b64encode(content_bytes).decode("utf-8")
317
437
  return {
318
- "mimeType": mime_type,
438
+ "file_id": file_id,
439
+ "name": file_name,
440
+ "mime_type": mime_type,
319
441
  "content": content,
320
442
  "encoding": "base64",
321
443
  }
322
444
  else:
323
445
  # Binary file
324
446
  content = base64.b64encode(content_bytes).decode("utf-8")
325
- return {"mimeType": mime_type, "content": content, "encoding": "base64"}
447
+ return {
448
+ "file_id": file_id,
449
+ "name": file_name,
450
+ "mime_type": mime_type,
451
+ "content": content,
452
+ "encoding": "base64",
453
+ }
326
454
 
327
455
  def _download_content(self, request) -> bytes:
328
456
  """Download content from a request."""
@@ -475,3 +603,98 @@ class DriveService(BaseGoogleService):
475
603
  "message": str(e),
476
604
  "operation": "list_shared_drives",
477
605
  }
606
+
607
+ def share_file_with_domain(
608
+ self, file_id: str, domain: str, role: str = "reader"
609
+ ) -> dict[str, Any]:
610
+ """
611
+ Shares a file with an entire domain.
612
+
613
+ Args:
614
+ file_id: The ID of the file to share.
615
+ domain: The domain to share the file with (e.g., 'example.com').
616
+ role: The permission role ('reader', 'commenter', 'writer'). Defaults to 'reader'.
617
+
618
+ Returns:
619
+ A dictionary containing the permission details or an error dictionary.
620
+ """
621
+ try:
622
+ if not file_id or not domain:
623
+ raise ValueError("File ID and domain are required.")
624
+
625
+ logger.info(
626
+ f"Sharing file {file_id} with domain '{domain}' as role '{role}'"
627
+ )
628
+
629
+ permission = {"type": "domain", "role": role, "domain": domain}
630
+
631
+ # Create the permission
632
+ permission_result = (
633
+ self.service.permissions()
634
+ .create(
635
+ fileId=file_id,
636
+ body=permission,
637
+ sendNotificationEmail=False, # Avoid spamming the domain
638
+ supportsAllDrives=True,
639
+ )
640
+ .execute()
641
+ )
642
+
643
+ logger.info(
644
+ f"Successfully created domain permission ID: {permission_result.get('id')}"
645
+ )
646
+ return {
647
+ "success": True,
648
+ "file_id": file_id,
649
+ "permission_id": permission_result.get("id"),
650
+ "domain": domain,
651
+ "role": role,
652
+ }
653
+
654
+ except HttpError as error:
655
+ # Check for a 403 error related to sharing policies
656
+ if error.resp.status == 403:
657
+ error_content = error.content.decode("utf-8")
658
+ if (
659
+ "cannotShareTeamDriveItem" in error_content
660
+ or "sharingRateLimitExceeded" in error_content
661
+ ):
662
+ logger.error(
663
+ f"Domain sharing policy prevents sharing file {file_id}: {error_content}"
664
+ )
665
+ # Return a more specific error message
666
+ return self.handle_api_error(
667
+ "share_file_with_domain_policy_error", error
668
+ )
669
+
670
+ return self.handle_api_error("share_file_with_domain", error)
671
+ except Exception as e:
672
+ return self.handle_api_error("share_file_with_domain", e)
673
+
674
+ def _get_or_create_data_folder(self) -> str:
675
+ """
676
+ Finds the dedicated folder for storing chart data, creating it if it doesn't exist.
677
+ The result is cached to avoid repeated API calls within the same session.
678
+
679
+ Returns:
680
+ The ID of the data folder.
681
+ """
682
+ folder_name = "[MCP] Generated Chart Data"
683
+ query = f"name = '{folder_name}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
684
+
685
+ logger.info(f"Searching for data folder: '{folder_name}'")
686
+ search_result = self.search_files(query=query, page_size=1)
687
+
688
+ if search_result and len(search_result) > 0:
689
+ folder_id = search_result[0]["id"]
690
+ logger.info(f"Found existing data folder with ID: {folder_id}")
691
+ return folder_id
692
+ logger.info("Data folder not found. Creating a new one.")
693
+ create_result = self.create_folder(folder_name=folder_name)
694
+ if create_result and not create_result.get("error"):
695
+ folder_id = create_result["id"]
696
+ logger.info(f"Successfully created data folder with ID: {folder_id}")
697
+ return folder_id
698
+ raise RuntimeError(
699
+ "Failed to create the necessary data folder in Google Drive."
700
+ )