feat: initial commit from workspace-mcp
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
This commit is contained in:
0
gdrive/__init__.py
Normal file
0
gdrive/__init__.py
Normal file
375
gdrive/drive_helpers.py
Normal file
375
gdrive/drive_helpers.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Google Drive Helper Functions
|
||||
|
||||
Shared utilities for Google Drive operations including permission checking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
VALID_SHARE_ROLES = {"reader", "commenter", "writer"}
|
||||
VALID_SHARE_TYPES = {"user", "group", "domain", "anyone"}
|
||||
|
||||
|
||||
def check_public_link_permission(permissions: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Check if file has 'anyone with the link' permission.
|
||||
|
||||
Args:
|
||||
permissions: List of permission objects from Google Drive API
|
||||
|
||||
Returns:
|
||||
bool: True if file has public link sharing enabled
|
||||
"""
|
||||
return any(
|
||||
p.get("type") == "anyone" and p.get("role") in ["reader", "writer", "commenter"]
|
||||
for p in permissions
|
||||
)
|
||||
|
||||
|
||||
def format_public_sharing_error(file_name: str, file_id: str) -> str:
|
||||
"""
|
||||
Format error message for files without public sharing.
|
||||
|
||||
Args:
|
||||
file_name: Name of the file
|
||||
file_id: Google Drive file ID
|
||||
|
||||
Returns:
|
||||
str: Formatted error message
|
||||
"""
|
||||
return (
|
||||
f"❌ Permission Error: '{file_name}' not shared publicly. "
|
||||
f"Set 'Anyone with the link' → 'Viewer' in Google Drive sharing. "
|
||||
f"File: https://drive.google.com/file/d/{file_id}/view"
|
||||
)
|
||||
|
||||
|
||||
def get_drive_image_url(file_id: str) -> str:
|
||||
"""
|
||||
Get the correct Drive URL format for publicly shared images.
|
||||
|
||||
Args:
|
||||
file_id: Google Drive file ID
|
||||
|
||||
Returns:
|
||||
str: URL for embedding Drive images
|
||||
"""
|
||||
return f"https://drive.google.com/uc?export=view&id={file_id}"
|
||||
|
||||
|
||||
def validate_share_role(role: str) -> None:
|
||||
"""
|
||||
Validate that the role is valid for sharing.
|
||||
|
||||
Args:
|
||||
role: The permission role to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If role is not reader, commenter, or writer
|
||||
"""
|
||||
if role not in VALID_SHARE_ROLES:
|
||||
raise ValueError(
|
||||
f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_SHARE_ROLES))}"
|
||||
)
|
||||
|
||||
|
||||
def validate_share_type(share_type: str) -> None:
|
||||
"""
|
||||
Validate that the share type is valid.
|
||||
|
||||
Args:
|
||||
share_type: The type of sharing to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If share_type is not user, group, domain, or anyone
|
||||
"""
|
||||
if share_type not in VALID_SHARE_TYPES:
|
||||
raise ValueError(
|
||||
f"Invalid share_type '{share_type}'. Must be one of: {', '.join(sorted(VALID_SHARE_TYPES))}"
|
||||
)
|
||||
|
||||
|
||||
RFC3339_PATTERN = re.compile(
|
||||
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
|
||||
)
|
||||
|
||||
|
||||
def validate_expiration_time(expiration_time: str) -> None:
|
||||
"""
|
||||
Validate that expiration_time is in RFC 3339 format.
|
||||
|
||||
Args:
|
||||
expiration_time: The expiration time string to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If expiration_time is not valid RFC 3339 format
|
||||
"""
|
||||
if not RFC3339_PATTERN.match(expiration_time):
|
||||
raise ValueError(
|
||||
f"Invalid expiration_time '{expiration_time}'. "
|
||||
"Must be RFC 3339 format (e.g., '2025-01-15T00:00:00Z')"
|
||||
)
|
||||
|
||||
|
||||
def format_permission_info(permission: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Format a permission object for display.
|
||||
|
||||
Args:
|
||||
permission: Permission object from Google Drive API
|
||||
|
||||
Returns:
|
||||
str: Human-readable permission description with ID
|
||||
"""
|
||||
perm_type = permission.get("type", "unknown")
|
||||
role = permission.get("role", "unknown")
|
||||
perm_id = permission.get("id", "")
|
||||
|
||||
if perm_type == "anyone":
|
||||
base = f"Anyone with the link ({role}) [id: {perm_id}]"
|
||||
elif perm_type == "user":
|
||||
email = permission.get("emailAddress", "unknown")
|
||||
base = f"User: {email} ({role}) [id: {perm_id}]"
|
||||
elif perm_type == "group":
|
||||
email = permission.get("emailAddress", "unknown")
|
||||
base = f"Group: {email} ({role}) [id: {perm_id}]"
|
||||
elif perm_type == "domain":
|
||||
domain = permission.get("domain", "unknown")
|
||||
base = f"Domain: {domain} ({role}) [id: {perm_id}]"
|
||||
else:
|
||||
base = f"{perm_type} ({role}) [id: {perm_id}]"
|
||||
|
||||
extras = []
|
||||
if permission.get("expirationTime"):
|
||||
extras.append(f"expires: {permission['expirationTime']}")
|
||||
|
||||
perm_details = permission.get("permissionDetails", [])
|
||||
if perm_details:
|
||||
for detail in perm_details:
|
||||
if detail.get("inherited") and detail.get("inheritedFrom"):
|
||||
extras.append(f"inherited from: {detail['inheritedFrom']}")
|
||||
break
|
||||
|
||||
if extras:
|
||||
return f"{base} | {', '.join(extras)}"
|
||||
return base
|
||||
|
||||
|
||||
# Precompiled regex patterns for Drive query detection
|
||||
DRIVE_QUERY_PATTERNS = [
|
||||
re.compile(r'\b\w+\s*(=|!=|>|<)\s*[\'"].*?[\'"]', re.IGNORECASE), # field = 'value'
|
||||
re.compile(r"\b\w+\s*(=|!=|>|<)\s*\d+", re.IGNORECASE), # field = number
|
||||
re.compile(r"\bcontains\b", re.IGNORECASE), # contains operator
|
||||
re.compile(r"\bin\s+parents\b", re.IGNORECASE), # in parents
|
||||
re.compile(r"\bhas\s*\{", re.IGNORECASE), # has {properties}
|
||||
re.compile(r"\btrashed\s*=\s*(true|false)\b", re.IGNORECASE), # trashed=true/false
|
||||
re.compile(r"\bstarred\s*=\s*(true|false)\b", re.IGNORECASE), # starred=true/false
|
||||
re.compile(
|
||||
r'[\'"][^\'"]+[\'"]\s+in\s+parents', re.IGNORECASE
|
||||
), # 'parentId' in parents
|
||||
re.compile(r"\bfullText\s+contains\b", re.IGNORECASE), # fullText contains
|
||||
re.compile(r"\bname\s*(=|contains)\b", re.IGNORECASE), # name = or name contains
|
||||
re.compile(r"\bmimeType\s*(=|!=)\b", re.IGNORECASE), # mimeType operators
|
||||
]
|
||||
|
||||
|
||||
def build_drive_list_params(
|
||||
query: str,
|
||||
page_size: int,
|
||||
drive_id: Optional[str] = None,
|
||||
include_items_from_all_drives: bool = True,
|
||||
corpora: Optional[str] = None,
|
||||
page_token: Optional[str] = None,
|
||||
detailed: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper function to build common list parameters for Drive API calls.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
page_size: Maximum number of items to return
|
||||
drive_id: Optional shared drive ID
|
||||
include_items_from_all_drives: Whether to include items from all drives
|
||||
corpora: Optional corpus specification
|
||||
page_token: Optional page token for pagination (from a previous nextPageToken)
|
||||
detailed: Whether to request size, modifiedTime, and webViewLink fields.
|
||||
Defaults to True to preserve existing behavior.
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for Drive API list calls
|
||||
"""
|
||||
if detailed:
|
||||
fields = "nextPageToken, files(id, name, mimeType, webViewLink, iconLink, modifiedTime, size)"
|
||||
else:
|
||||
fields = "nextPageToken, files(id, name, mimeType)"
|
||||
list_params = {
|
||||
"q": query,
|
||||
"pageSize": page_size,
|
||||
"fields": fields,
|
||||
"supportsAllDrives": True,
|
||||
"includeItemsFromAllDrives": include_items_from_all_drives,
|
||||
}
|
||||
|
||||
if page_token:
|
||||
list_params["pageToken"] = page_token
|
||||
|
||||
if drive_id:
|
||||
list_params["driveId"] = drive_id
|
||||
if corpora:
|
||||
list_params["corpora"] = corpora
|
||||
else:
|
||||
list_params["corpora"] = "drive"
|
||||
elif corpora:
|
||||
list_params["corpora"] = corpora
|
||||
|
||||
return list_params
|
||||
|
||||
|
||||
SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"
|
||||
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
|
||||
|
||||
# RFC 6838 token-style MIME type validation (safe for Drive query interpolation).
|
||||
MIME_TYPE_PATTERN = re.compile(r"^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$")
|
||||
|
||||
# Mapping from friendly type names to Google Drive MIME types.
|
||||
# Raw MIME type strings (containing '/') are always accepted as-is.
|
||||
FILE_TYPE_MIME_MAP: Dict[str, str] = {
|
||||
"folder": "application/vnd.google-apps.folder",
|
||||
"folders": "application/vnd.google-apps.folder",
|
||||
"document": "application/vnd.google-apps.document",
|
||||
"doc": "application/vnd.google-apps.document",
|
||||
"documents": "application/vnd.google-apps.document",
|
||||
"docs": "application/vnd.google-apps.document",
|
||||
"spreadsheet": "application/vnd.google-apps.spreadsheet",
|
||||
"sheet": "application/vnd.google-apps.spreadsheet",
|
||||
"spreadsheets": "application/vnd.google-apps.spreadsheet",
|
||||
"sheets": "application/vnd.google-apps.spreadsheet",
|
||||
"presentation": "application/vnd.google-apps.presentation",
|
||||
"presentations": "application/vnd.google-apps.presentation",
|
||||
"slide": "application/vnd.google-apps.presentation",
|
||||
"slides": "application/vnd.google-apps.presentation",
|
||||
"form": "application/vnd.google-apps.form",
|
||||
"forms": "application/vnd.google-apps.form",
|
||||
"drawing": "application/vnd.google-apps.drawing",
|
||||
"drawings": "application/vnd.google-apps.drawing",
|
||||
"pdf": "application/pdf",
|
||||
"pdfs": "application/pdf",
|
||||
"shortcut": "application/vnd.google-apps.shortcut",
|
||||
"shortcuts": "application/vnd.google-apps.shortcut",
|
||||
"script": "application/vnd.google-apps.script",
|
||||
"scripts": "application/vnd.google-apps.script",
|
||||
"site": "application/vnd.google-apps.site",
|
||||
"sites": "application/vnd.google-apps.site",
|
||||
"jam": "application/vnd.google-apps.jam",
|
||||
"jamboard": "application/vnd.google-apps.jam",
|
||||
"jamboards": "application/vnd.google-apps.jam",
|
||||
}
|
||||
|
||||
|
||||
def resolve_file_type_mime(file_type: str) -> str:
|
||||
"""
|
||||
Resolve a friendly file type name or raw MIME type string to a Drive MIME type.
|
||||
|
||||
If `file_type` contains '/' it is returned as-is (treated as a raw MIME type).
|
||||
Otherwise it is looked up in FILE_TYPE_MIME_MAP.
|
||||
|
||||
Args:
|
||||
file_type: A friendly name ('folder', 'document', 'pdf', …) or a raw MIME
|
||||
type string ('application/vnd.google-apps.document', …).
|
||||
|
||||
Returns:
|
||||
str: The resolved MIME type string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value is not a recognised friendly name and contains no '/'.
|
||||
"""
|
||||
normalized = file_type.strip()
|
||||
if not normalized:
|
||||
raise ValueError("file_type cannot be empty.")
|
||||
|
||||
if "/" in normalized:
|
||||
normalized_mime = normalized.lower()
|
||||
if not MIME_TYPE_PATTERN.fullmatch(normalized_mime):
|
||||
raise ValueError(
|
||||
f"Invalid MIME type '{file_type}'. Expected format like 'application/pdf'."
|
||||
)
|
||||
return normalized_mime
|
||||
lower = normalized.lower()
|
||||
if lower not in FILE_TYPE_MIME_MAP:
|
||||
valid = ", ".join(sorted(FILE_TYPE_MIME_MAP.keys()))
|
||||
raise ValueError(
|
||||
f"Unknown file_type '{file_type}'. Pass a MIME type directly (e.g. "
|
||||
f"'application/pdf') or use one of the friendly names: {valid}"
|
||||
)
|
||||
return FILE_TYPE_MIME_MAP[lower]
|
||||
|
||||
|
||||
BASE_SHORTCUT_FIELDS = (
|
||||
"id, mimeType, parents, shortcutDetails(targetId, targetMimeType)"
|
||||
)
|
||||
|
||||
|
||||
async def resolve_drive_item(
|
||||
service,
|
||||
file_id: str,
|
||||
*,
|
||||
extra_fields: Optional[str] = None,
|
||||
max_depth: int = 5,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Resolve a Drive shortcut so downstream callers operate on the real item.
|
||||
|
||||
Returns the resolved file ID and its metadata. Raises if shortcut targets loop
|
||||
or exceed max_depth to avoid infinite recursion.
|
||||
"""
|
||||
current_id = file_id
|
||||
depth = 0
|
||||
fields = BASE_SHORTCUT_FIELDS
|
||||
if extra_fields:
|
||||
fields = f"{fields}, {extra_fields}"
|
||||
|
||||
while True:
|
||||
metadata = await asyncio.to_thread(
|
||||
service.files()
|
||||
.get(fileId=current_id, fields=fields, supportsAllDrives=True)
|
||||
.execute
|
||||
)
|
||||
mime_type = metadata.get("mimeType")
|
||||
if mime_type != SHORTCUT_MIME_TYPE:
|
||||
return current_id, metadata
|
||||
|
||||
shortcut_details = metadata.get("shortcutDetails") or {}
|
||||
target_id = shortcut_details.get("targetId")
|
||||
if not target_id:
|
||||
raise Exception(f"Shortcut '{current_id}' is missing target details.")
|
||||
|
||||
depth += 1
|
||||
if depth > max_depth:
|
||||
raise Exception(
|
||||
f"Shortcut resolution exceeded {max_depth} hops starting from '{file_id}'."
|
||||
)
|
||||
current_id = target_id
|
||||
|
||||
|
||||
async def resolve_folder_id(
|
||||
service,
|
||||
folder_id: str,
|
||||
*,
|
||||
max_depth: int = 5,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve a folder ID that might be a shortcut and ensure the final target is a folder.
|
||||
"""
|
||||
resolved_id, metadata = await resolve_drive_item(
|
||||
service,
|
||||
folder_id,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
mime_type = metadata.get("mimeType")
|
||||
if mime_type != FOLDER_MIME_TYPE:
|
||||
raise Exception(
|
||||
f"Resolved ID '{resolved_id}' (from '{folder_id}') is not a folder; mimeType={mime_type}."
|
||||
)
|
||||
return resolved_id
|
||||
2383
gdrive/drive_tools.py
Normal file
2383
gdrive/drive_tools.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user