|
27 | 27 | from core.utils import ( |
28 | 28 | GOOGLE_API_WRITE_RETRIES, |
29 | 29 | IMAGE_MIME_TYPES, |
| 30 | + UserInputError, |
30 | 31 | encode_image_content, |
31 | 32 | extract_office_xml_text, |
32 | 33 | extract_pdf_text, |
@@ -920,6 +921,26 @@ async def create_drive_file( |
920 | 921 | f"[create_drive_file] Invoked. Email: '{user_google_email}', File Name: {file_name}, Folder ID: {folder_id}, fileUrl: {fileUrl}" |
921 | 922 | ) |
922 | 923 |
|
| 924 | + # Auto-detect MIME type from file extension when caller uses the default |
| 925 | + if mime_type == "text/plain": |
| 926 | + _ext_mime_map = { |
| 927 | + ".md": "text/markdown", |
| 928 | + ".markdown": "text/markdown", |
| 929 | + ".csv": "text/csv", |
| 930 | + ".html": "text/html", |
| 931 | + ".htm": "text/html", |
| 932 | + ".xml": "text/xml", |
| 933 | + ".json": "application/json", |
| 934 | + ".yaml": "text/yaml", |
| 935 | + ".yml": "text/yaml", |
| 936 | + } |
| 937 | + _ext = Path(file_name).suffix.lower() |
| 938 | + if _ext in _ext_mime_map: |
| 939 | + mime_type = _ext_mime_map[_ext] |
| 940 | + logger.info( |
| 941 | + f"[create_drive_file] Auto-detected MIME type '{mime_type}' from extension '{_ext}'" |
| 942 | + ) |
| 943 | + |
923 | 944 | has_existing_content_source = content is not None or bool(fileUrl) |
924 | 945 | if ( |
925 | 946 | not has_existing_content_source |
@@ -1974,6 +1995,190 @@ async def _resolve_parent_arguments(parent_arg: Optional[str]) -> Optional[str]: |
1974 | 1995 | return "\n".join(output_parts) |
1975 | 1996 |
|
1976 | 1997 |
|
| 1998 | +UPDATABLE_TEXT_MIME_TYPES = { |
| 1999 | + "text/plain", |
| 2000 | + "text/markdown", |
| 2001 | + "text/x-markdown", |
| 2002 | + "text/csv", |
| 2003 | + "text/html", |
| 2004 | + "text/xml", |
| 2005 | + "text/yaml", |
| 2006 | + "application/json", |
| 2007 | + "application/xml", |
| 2008 | + "application/x-yaml", |
| 2009 | +} |
| 2010 | + |
| 2011 | + |
| 2012 | +@server.tool() |
| 2013 | +@handle_http_errors( |
| 2014 | + "update_drive_file_content", is_read_only=False, service_type="drive" |
| 2015 | +) |
| 2016 | +@require_google_service("drive", "drive_file") |
| 2017 | +async def update_drive_file_content( |
| 2018 | + service, |
| 2019 | + user_google_email: str, |
| 2020 | + file_id: str, |
| 2021 | + content: str, |
| 2022 | + mime_type: Optional[str] = None, |
| 2023 | +) -> str: |
| 2024 | + """ |
| 2025 | + Updates the content of an existing text-based file in Google Drive. |
| 2026 | +
|
| 2027 | + Overwrites the entire file content. Works with uploaded text files |
| 2028 | + (.md, .txt, .csv, .json, .html, .xml, .yaml). Does NOT work with |
| 2029 | + native Google Docs/Sheets/Slides — use modify_doc_text or |
| 2030 | + batch_update_doc for those. |
| 2031 | +
|
| 2032 | + Args: |
| 2033 | + user_google_email (str): The user's Google email address. Required. |
| 2034 | + file_id (str): The ID of the file to update. Required. |
| 2035 | + content (str): The new file content. Overwrites existing content entirely. |
| 2036 | + mime_type (Optional[str]): MIME type for the upload. Defaults to the |
| 2037 | + file's existing MIME type. If provided, changes the stored MIME type. |
| 2038 | +
|
| 2039 | + Returns: |
| 2040 | + str: Confirmation message with file name, ID, character count, and link. |
| 2041 | + """ |
| 2042 | + logger.info( |
| 2043 | + f"[update_drive_file_content] Updating content of {file_id} for {user_google_email}" |
| 2044 | + ) |
| 2045 | + |
| 2046 | + resolved_file_id, current_file = await resolve_drive_item( |
| 2047 | + service, |
| 2048 | + file_id, |
| 2049 | + extra_fields="name, mimeType, webViewLink", |
| 2050 | + ) |
| 2051 | + file_id = resolved_file_id |
| 2052 | + current_mime = current_file.get("mimeType", "text/plain") |
| 2053 | + |
| 2054 | + if current_mime.startswith("application/vnd.google-apps"): |
| 2055 | + raise UserInputError( |
| 2056 | + f"Cannot update content of native Google file (type: {current_mime}). " |
| 2057 | + "Use modify_doc_text or batch_update_doc for Google Docs, " |
| 2058 | + "or modify_sheet_values for Google Sheets." |
| 2059 | + ) |
| 2060 | + |
| 2061 | + if current_mime not in UPDATABLE_TEXT_MIME_TYPES: |
| 2062 | + raise UserInputError( |
| 2063 | + f"Cannot update content of file with MIME type '{current_mime}'. " |
| 2064 | + "Only text-based files are supported: " |
| 2065 | + ".md, .txt, .csv, .json, .html, .xml, .yaml" |
| 2066 | + ) |
| 2067 | + |
| 2068 | + if mime_type is not None: |
| 2069 | + mime_type = mime_type.strip() |
| 2070 | + if not mime_type: |
| 2071 | + raise UserInputError("mime_type cannot be empty when provided.") |
| 2072 | + if mime_type not in UPDATABLE_TEXT_MIME_TYPES: |
| 2073 | + raise UserInputError( |
| 2074 | + f"Cannot use MIME type '{mime_type}' for content update. " |
| 2075 | + "Only text-based MIME types are supported: " |
| 2076 | + + ", ".join(sorted(UPDATABLE_TEXT_MIME_TYPES)) |
| 2077 | + ) |
| 2078 | + content_mime = mime_type |
| 2079 | + else: |
| 2080 | + content_mime = current_mime |
| 2081 | + |
| 2082 | + media = MediaIoBaseUpload( |
| 2083 | + io.BytesIO(content.encode("utf-8")), |
| 2084 | + mimetype=content_mime, |
| 2085 | + resumable=False, |
| 2086 | + ) |
| 2087 | + |
| 2088 | + update_kwargs: Dict[str, Any] = { |
| 2089 | + "fileId": file_id, |
| 2090 | + "media_body": media, |
| 2091 | + "fields": "id, name, mimeType, modifiedTime, webViewLink", |
| 2092 | + "supportsAllDrives": True, |
| 2093 | + } |
| 2094 | + if mime_type is not None: |
| 2095 | + update_kwargs["body"] = {"mimeType": content_mime} |
| 2096 | + |
| 2097 | + updated_file = await asyncio.to_thread( |
| 2098 | + service.files().update(**update_kwargs).execute |
| 2099 | + ) |
| 2100 | + |
| 2101 | + file_name = updated_file.get("name", current_file.get("name", "unknown")) |
| 2102 | + char_count = len(content) |
| 2103 | + |
| 2104 | + output_parts = [ |
| 2105 | + f"Updated content of '{file_name}' (ID: {file_id}, {char_count} characters)", |
| 2106 | + f"Modified: {updated_file.get('modifiedTime', 'unknown')}", |
| 2107 | + f"View file: {updated_file.get('webViewLink', '#')}", |
| 2108 | + ] |
| 2109 | + |
| 2110 | + return "\n".join(output_parts) |
| 2111 | + |
| 2112 | + |
| 2113 | +@server.tool() |
| 2114 | +@handle_http_errors("delete_drive_file", is_read_only=False, service_type="drive") |
| 2115 | +@require_google_service("drive", "drive_file") |
| 2116 | +async def delete_drive_file( |
| 2117 | + service, |
| 2118 | + user_google_email: str, |
| 2119 | + file_id: str, |
| 2120 | + permanent: bool = False, |
| 2121 | +) -> str: |
| 2122 | + """ |
| 2123 | + Deletes a file from Google Drive. Use this tool when asked to delete, |
| 2124 | + remove, or trash a file. |
| 2125 | +
|
| 2126 | + By default moves the file to trash (recoverable for 30 days). |
| 2127 | + Set permanent=True to permanently delete (not recoverable). |
| 2128 | +
|
| 2129 | + Args: |
| 2130 | + user_google_email (str): The user's Google email address. Required. |
| 2131 | + file_id (str): The ID of the file to delete. Required. |
| 2132 | + permanent (bool): If True, permanently deletes the file (not recoverable). |
| 2133 | + If False (default), moves to trash. If already trashed, returns a |
| 2134 | + message indicating the file is already in trash. |
| 2135 | +
|
| 2136 | + Returns: |
| 2137 | + str: Confirmation message describing what happened. |
| 2138 | + """ |
| 2139 | + logger.info( |
| 2140 | + f"[delete_drive_file] Deleting {file_id} for {user_google_email} (permanent={permanent})" |
| 2141 | + ) |
| 2142 | + |
| 2143 | + resolved_file_id, current_file = await resolve_drive_item( |
| 2144 | + service, |
| 2145 | + file_id, |
| 2146 | + extra_fields="name, mimeType, trashed", |
| 2147 | + ) |
| 2148 | + file_id = resolved_file_id |
| 2149 | + file_name = current_file.get("name", "unknown") |
| 2150 | + already_trashed = current_file.get("trashed", False) |
| 2151 | + |
| 2152 | + if permanent: |
| 2153 | + await asyncio.to_thread( |
| 2154 | + service.files() |
| 2155 | + .delete( |
| 2156 | + fileId=file_id, |
| 2157 | + supportsAllDrives=True, |
| 2158 | + ) |
| 2159 | + .execute |
| 2160 | + ) |
| 2161 | + return f"Permanently deleted '{file_name}' (ID: {file_id})." |
| 2162 | + |
| 2163 | + if already_trashed: |
| 2164 | + return ( |
| 2165 | + f"File '{file_name}' (ID: {file_id}) is already in trash. " |
| 2166 | + "Use permanent=True to permanently delete it." |
| 2167 | + ) |
| 2168 | + |
| 2169 | + await asyncio.to_thread( |
| 2170 | + service.files() |
| 2171 | + .update( |
| 2172 | + fileId=file_id, |
| 2173 | + body={"trashed": True}, |
| 2174 | + fields="id", |
| 2175 | + supportsAllDrives=True, |
| 2176 | + ) |
| 2177 | + .execute |
| 2178 | + ) |
| 2179 | + return f"Moved '{file_name}' (ID: {file_id}) to trash. Use permanent=True to permanently delete." |
| 2180 | + |
| 2181 | + |
1977 | 2182 | @server.tool( |
1978 | 2183 | title="Get Drive Shareable Link", |
1979 | 2184 | annotations=ToolAnnotations( |
|
0 commit comments