arcade-google-slides 0.1.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.
@@ -0,0 +1,17 @@
1
+ from arcade_google_slides.tools import (
2
+ comment_on_presentation,
3
+ create_presentation,
4
+ create_slide,
5
+ get_presentation_as_markdown,
6
+ list_presentation_comments,
7
+ search_presentations,
8
+ )
9
+
10
+ __all__ = [
11
+ "create_presentation",
12
+ "create_slide",
13
+ "get_presentation_as_markdown",
14
+ "search_presentations",
15
+ "comment_on_presentation",
16
+ "list_presentation_comments",
17
+ ]
@@ -0,0 +1,329 @@
1
+ """
2
+ Modular converter class for converting Google Slides presentations to Markdown.
3
+ This converter strictly follows the TypedDict structure definitions.
4
+ """
5
+
6
+ import logging
7
+
8
+ from arcade_google_slides.types import (
9
+ Bullet,
10
+ Page,
11
+ PageElement,
12
+ ParagraphMarker,
13
+ Presentation,
14
+ Shape,
15
+ TextContent,
16
+ TextElement,
17
+ TextRun,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class BulletConverter:
24
+ """Converts bullet elements to markdown."""
25
+
26
+ def convert(self, bullet: Bullet) -> str:
27
+ """
28
+ Convert a bullet to markdown representation.
29
+
30
+ Args:
31
+ bullet: Bullet TypedDict with glyph and nestingLevel
32
+
33
+ Returns:
34
+ Markdown string with appropriate indentation and markdown bullet syntax
35
+ """
36
+ nesting_level = bullet.get("nestingLevel", 0)
37
+ indent = " " * nesting_level
38
+ return f"{indent}* "
39
+
40
+
41
+ class ParagraphMarkerConverter:
42
+ """Converts paragraph markers to markdown."""
43
+
44
+ def __init__(self) -> None:
45
+ self.bullet_converter = BulletConverter()
46
+
47
+ def convert(self, paragraph_marker: ParagraphMarker) -> str:
48
+ """
49
+ Convert a paragraph marker to markdown.
50
+
51
+ Args:
52
+ paragraph_marker: ParagraphMarker TypedDict
53
+
54
+ Returns:
55
+ Markdown string representation
56
+ """
57
+ if "bullet" in paragraph_marker:
58
+ return self.bullet_converter.convert(paragraph_marker["bullet"])
59
+ return ""
60
+
61
+
62
+ class TextRunConverter:
63
+ """Converts text runs to markdown with styling support."""
64
+
65
+ def convert(self, text_run: TextRun) -> str:
66
+ """
67
+ Convert a text run to markdown with styling.
68
+
69
+ Args:
70
+ text_run: TextRun TypedDict with content and optional style
71
+
72
+ Returns:
73
+ The text content with markdown formatting applied
74
+ """
75
+ content = text_run.get("content", "")
76
+
77
+ style = text_run.get("style")
78
+ if not style:
79
+ return content
80
+
81
+ # Extract leading and trailing spaces to apply formatting correctly
82
+ # Markdown formatting must be adjacent to text, not spaces
83
+ leading_spaces = len(content) - len(content.lstrip())
84
+ trailing_spaces = len(content) - len(content.rstrip())
85
+
86
+ # Get the spaces
87
+ prefix = content[:leading_spaces] if leading_spaces > 0 else ""
88
+ suffix = content[-trailing_spaces:] if trailing_spaces > 0 else ""
89
+
90
+ trimmed_content = content.strip()
91
+
92
+ if not trimmed_content:
93
+ return content
94
+
95
+ # Apply text formatting to the trimmed content
96
+ # Note: Order matters for proper markdown rendering
97
+
98
+ if style.get("strikethrough", False):
99
+ trimmed_content = f"~~{trimmed_content}~~"
100
+
101
+ is_bold = style.get("bold", False)
102
+ is_italic = style.get("italic", False)
103
+
104
+ if is_bold and is_italic:
105
+ # Both bold and italic: ***text***
106
+ trimmed_content = f"***{trimmed_content}***"
107
+ elif is_bold:
108
+ # Bold only: **text**
109
+ trimmed_content = f"**{trimmed_content}**"
110
+ elif is_italic:
111
+ # Italic only: *text*
112
+ trimmed_content = f"*{trimmed_content}*"
113
+
114
+ # Note: This may not render in all markdown viewers
115
+ if style.get("underline", False):
116
+ trimmed_content = f"<u>{trimmed_content}</u>"
117
+
118
+ link = style.get("link")
119
+ if link:
120
+ url = link.get("url", "")
121
+ if url:
122
+ trimmed_content = f"[{trimmed_content}]({url})"
123
+
124
+ return prefix + trimmed_content + suffix
125
+
126
+
127
+ class TextElementConverter:
128
+ """Converts text elements to markdown."""
129
+
130
+ def __init__(self) -> None:
131
+ self.paragraph_marker_converter = ParagraphMarkerConverter()
132
+ self.text_run_converter = TextRunConverter()
133
+
134
+ def convert(self, text_element: TextElement) -> str:
135
+ """
136
+ Convert a text element to markdown.
137
+
138
+ Args:
139
+ text_element: Either TextElementWithParagraphMarker or TextElementWithTextRun
140
+
141
+ Returns:
142
+ Markdown string representation
143
+ """
144
+ if "paragraphMarker" in text_element:
145
+ return self.paragraph_marker_converter.convert(text_element["paragraphMarker"]) # type: ignore[typeddict-item]
146
+
147
+ elif "textRun" in text_element:
148
+ return self.text_run_converter.convert(text_element["textRun"])
149
+
150
+ return ""
151
+
152
+
153
+ class TextContentConverter:
154
+ """Converts text content to markdown."""
155
+
156
+ def __init__(self) -> None:
157
+ self.text_element_converter = TextElementConverter()
158
+
159
+ def convert(self, text_content: TextContent) -> str:
160
+ """
161
+ Convert text content to markdown.
162
+
163
+ Args:
164
+ text_content: TextContent TypedDict with textElements list
165
+
166
+ Returns:
167
+ Markdown string representation
168
+ """
169
+ markdown_parts = []
170
+
171
+ text_elements = text_content.get("textElements", [])
172
+
173
+ current_line = ""
174
+ for element in text_elements:
175
+ converted = self.text_element_converter.convert(element)
176
+
177
+ # If it's a bullet marker, start a new line if needed
178
+ if "paragraphMarker" in element and "bullet" in element["paragraphMarker"]: # type: ignore[typeddict-item]
179
+ if current_line:
180
+ markdown_parts.append(current_line)
181
+ current_line = converted
182
+ else:
183
+ current_line += converted
184
+
185
+ # Check if the text run ends with a newline
186
+ if "textRun" in element:
187
+ content = element["textRun"].get("content", "") # type: ignore[typeddict-item]
188
+ if content.endswith("\n"):
189
+ markdown_parts.append(current_line.rstrip("\n"))
190
+ current_line = ""
191
+
192
+ # Add any remaining text
193
+ if current_line:
194
+ markdown_parts.append(current_line)
195
+
196
+ return "\n".join(markdown_parts)
197
+
198
+
199
+ class ShapeConverter:
200
+ """Converts shapes to markdown."""
201
+
202
+ def __init__(self) -> None:
203
+ self.text_content_converter = TextContentConverter()
204
+
205
+ def convert(self, shape: Shape) -> str:
206
+ """
207
+ Convert a shape to markdown.
208
+
209
+ Args:
210
+ shape: Shape TypedDict with shapeType and text
211
+
212
+ Returns:
213
+ Markdown string representation
214
+ """
215
+ markdown = ""
216
+
217
+ if "text" in shape:
218
+ text_markdown = self.text_content_converter.convert(shape["text"])
219
+ if text_markdown:
220
+ markdown += text_markdown
221
+ if not text_markdown.endswith("\n"):
222
+ markdown += "\n"
223
+
224
+ return markdown
225
+
226
+
227
+ class PageElementConverter:
228
+ """Converts page elements to markdown."""
229
+
230
+ def __init__(self) -> None:
231
+ self.shape_converter = ShapeConverter()
232
+
233
+ def convert(self, page_element: PageElement) -> str:
234
+ """
235
+ Convert a page element to markdown.
236
+
237
+ Args:
238
+ page_element: PageElement TypedDict with objectId and shape
239
+
240
+ Returns:
241
+ Markdown string representation
242
+ """
243
+ if "shape" in page_element:
244
+ return self.shape_converter.convert(page_element["shape"])
245
+ return ""
246
+
247
+
248
+ class PageConverter:
249
+ """Converts pages (slides) to markdown."""
250
+
251
+ def __init__(self) -> None:
252
+ self.page_element_converter = PageElementConverter()
253
+
254
+ def convert(self, page: Page, page_number: int) -> str:
255
+ """
256
+ Convert a page (slide) to markdown.
257
+
258
+ Args:
259
+ page: Page TypedDict with objectId and pageElements
260
+ page_number: The page/slide number (1-indexed)
261
+
262
+ Returns:
263
+ Markdown string representation
264
+ """
265
+ is_hidden = page.get("slideProperties", {}).get("isSkipped", False)
266
+ is_hidden_str = " (hidden)" if is_hidden else ""
267
+ markdown = f"## Slide {page_number}{is_hidden_str}\n\n"
268
+
269
+ slide_id = page.get("objectId", "")
270
+ if slide_id:
271
+ markdown += f"**Slide ID:** {slide_id}\n\n"
272
+
273
+ # Process all page elements
274
+ page_elements = page.get("pageElements", [])
275
+ for element in page_elements:
276
+ element_markdown = self.page_element_converter.convert(element)
277
+ if element_markdown:
278
+ markdown += element_markdown
279
+ if not element_markdown.endswith("\n\n"):
280
+ markdown += "\n"
281
+
282
+ return markdown
283
+
284
+
285
+ class PresentationMarkdownConverter:
286
+ """
287
+ Main converter class for converting Google Slides presentations to markdown.
288
+ This converter strictly follows the TypedDict structure definitions.
289
+ """
290
+
291
+ def __init__(self) -> None:
292
+ self.page_converter = PageConverter()
293
+
294
+ def convert(self, presentation: Presentation) -> str:
295
+ """
296
+ Convert a Google Slides presentation to markdown format.
297
+
298
+ Args:
299
+ presentation: Presentation TypedDict with presentationId, title, and slides
300
+
301
+ Returns:
302
+ A markdown string representation of the presentation
303
+ """
304
+ if not presentation:
305
+ return ""
306
+
307
+ # Extract metadata
308
+ title = presentation.get("title", "Untitled Presentation")
309
+ presentation_id = presentation.get("presentationId", "")
310
+ slides = presentation.get("slides", [])
311
+
312
+ # Build markdown
313
+ markdown = f"# {title}\n\n"
314
+
315
+ if presentation_id:
316
+ markdown += f"**Presentation ID:** {presentation_id}\n\n"
317
+
318
+ if slides:
319
+ markdown += "---\n\n"
320
+
321
+ for i, slide in enumerate(slides, 1):
322
+ slide_markdown = self.page_converter.convert(slide, i)
323
+ markdown += slide_markdown
324
+
325
+ # Adds separator between slides
326
+ if i < len(slides):
327
+ markdown += "\n---\n\n"
328
+
329
+ return markdown.strip()
@@ -0,0 +1,24 @@
1
+ import functools
2
+ from collections.abc import Callable
3
+ from typing import Any
4
+
5
+ from arcade_tdk import ToolContext
6
+ from googleapiclient.errors import HttpError
7
+
8
+ from arcade_google_slides.file_picker import generate_google_file_picker_url
9
+
10
+
11
+ def with_filepicker_fallback(func: Callable[..., Any]) -> Callable[..., Any]:
12
+ """ """
13
+
14
+ @functools.wraps(func)
15
+ async def async_wrapper(context: ToolContext, *args: Any, **kwargs: Any) -> Any:
16
+ try:
17
+ return await func(context, *args, **kwargs)
18
+ except HttpError as e:
19
+ if e.status_code in [403, 404]:
20
+ file_picker_response = generate_google_file_picker_url(context)
21
+ return file_picker_response
22
+ raise
23
+
24
+ return async_wrapper
@@ -0,0 +1,165 @@
1
+ from enum import Enum
2
+
3
+
4
+ class PredefinedLayout(str, Enum):
5
+ """Partial implementation of the REST Resource PredefinedLayout.
6
+
7
+ Reference: https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/request#PredefinedLayout
8
+ """
9
+
10
+ BLANK = "BLANK" # Blank layout, with no placeholders.
11
+ CAPTION_ONLY = "CAPTION_ONLY" # Layout with a caption at the bottom.
12
+ TITLE = "TITLE" # Layout with a title and a subtitle.
13
+ TITLE_AND_BODY = "TITLE_AND_BODY" # Layout with a title and body.
14
+ TITLE_AND_TWO_COLUMNS = "TITLE_AND_TWO_COLUMNS" # Layout with a title and two columns.
15
+ TITLE_ONLY = "TITLE_ONLY" # Layout with only a title.
16
+ SECTION_HEADER = "SECTION_HEADER" # Layout with a section title.
17
+ SECTION_TITLE_AND_DESCRIPTION = "SECTION_TITLE_AND_DESCRIPTION" # Layout with a title and subtitle on one side and description on the other. # noqa: E501
18
+ ONE_COLUMN_TEXT = (
19
+ "ONE_COLUMN_TEXT" # Layout with one title and one body, arranged in a single column.
20
+ )
21
+ MAIN_POINT = "MAIN_POINT" # Layout with a main point.
22
+ BIG_NUMBER = "BIG_NUMBER" # Layout with a big number heading.
23
+
24
+
25
+ class ShapeType(Enum):
26
+ """
27
+ The type of shape. For now, only TEXT_BOX is supported.
28
+
29
+ Reference: https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations.pages/shapes#type
30
+ """
31
+
32
+ TEXT_BOX = "TEXT_BOX"
33
+ # TODO: Support rectangle or table cell?
34
+
35
+
36
+ class PlaceholderType(str, Enum):
37
+ """Partial implementation of the REST Resource 'Type'.
38
+
39
+ The type of placeholder shape.
40
+
41
+ Reference: https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations.pages/other#Page.Type_3
42
+ """
43
+
44
+ TITLE = "TITLE"
45
+ BODY = "BODY"
46
+
47
+
48
+ # --------------------------------------------------------- #
49
+ # Drive API Enums
50
+ # --------------------------------------------------------- #
51
+
52
+
53
+ class Corpora(str, Enum):
54
+ """
55
+ Bodies of items (files/documents) to which the query applies.
56
+ Prefer 'user' or 'drive' to 'allDrives' for efficiency.
57
+ By default, corpora is set to 'user'.
58
+ """
59
+
60
+ USER = "user"
61
+ DOMAIN = "domain"
62
+ DRIVE = "drive"
63
+ ALL_DRIVES = "allDrives"
64
+
65
+
66
+ class DocumentFormat(str, Enum):
67
+ MARKDOWN = "markdown"
68
+ HTML = "html"
69
+ GOOGLE_API_JSON = "google_api_json"
70
+
71
+
72
+ class OrderBy(str, Enum):
73
+ """
74
+ Sort keys for ordering files in Google Drive.
75
+ Each key has both ascending and descending options.
76
+ """
77
+
78
+ CREATED_TIME = (
79
+ # When the file was created (ascending)
80
+ "createdTime"
81
+ )
82
+ CREATED_TIME_DESC = (
83
+ # When the file was created (descending)
84
+ "createdTime desc"
85
+ )
86
+ FOLDER = (
87
+ # The folder ID, sorted using alphabetical ordering (ascending)
88
+ "folder"
89
+ )
90
+ FOLDER_DESC = (
91
+ # The folder ID, sorted using alphabetical ordering (descending)
92
+ "folder desc"
93
+ )
94
+ MODIFIED_BY_ME_TIME = (
95
+ # The last time the file was modified by the user (ascending)
96
+ "modifiedByMeTime"
97
+ )
98
+ MODIFIED_BY_ME_TIME_DESC = (
99
+ # The last time the file was modified by the user (descending)
100
+ "modifiedByMeTime desc"
101
+ )
102
+ MODIFIED_TIME = (
103
+ # The last time the file was modified by anyone (ascending)
104
+ "modifiedTime"
105
+ )
106
+ MODIFIED_TIME_DESC = (
107
+ # The last time the file was modified by anyone (descending)
108
+ "modifiedTime desc"
109
+ )
110
+ NAME = (
111
+ # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending)
112
+ "name"
113
+ )
114
+ NAME_DESC = (
115
+ # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending)
116
+ "name desc"
117
+ )
118
+ NAME_NATURAL = (
119
+ # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending)
120
+ "name_natural"
121
+ )
122
+ NAME_NATURAL_DESC = (
123
+ # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending)
124
+ "name_natural desc"
125
+ )
126
+ QUOTA_BYTES_USED = (
127
+ # The number of storage quota bytes used by the file (ascending)
128
+ "quotaBytesUsed"
129
+ )
130
+ QUOTA_BYTES_USED_DESC = (
131
+ # The number of storage quota bytes used by the file (descending)
132
+ "quotaBytesUsed desc"
133
+ )
134
+ RECENCY = (
135
+ # The most recent timestamp from the file's date-time fields (ascending)
136
+ "recency"
137
+ )
138
+ RECENCY_DESC = (
139
+ # The most recent timestamp from the file's date-time fields (descending)
140
+ "recency desc"
141
+ )
142
+ SHARED_WITH_ME_TIME = (
143
+ # When the file was shared with the user, if applicable (ascending)
144
+ "sharedWithMeTime"
145
+ )
146
+ SHARED_WITH_ME_TIME_DESC = (
147
+ # When the file was shared with the user, if applicable (descending)
148
+ "sharedWithMeTime desc"
149
+ )
150
+ STARRED = (
151
+ # Whether the user has starred the file (ascending)
152
+ "starred"
153
+ )
154
+ STARRED_DESC = (
155
+ # Whether the user has starred the file (descending)
156
+ "starred desc"
157
+ )
158
+ VIEWED_BY_ME_TIME = (
159
+ # The last time the file was viewed by the user (ascending)
160
+ "viewedByMeTime"
161
+ )
162
+ VIEWED_BY_ME_TIME_DESC = (
163
+ # The last time the file was viewed by the user (descending)
164
+ "viewedByMeTime desc"
165
+ )
@@ -0,0 +1,49 @@
1
+ import base64
2
+ import json
3
+
4
+ from arcade_tdk import ToolContext, ToolMetadataKey
5
+ from arcade_tdk.errors import ToolExecutionError
6
+
7
+
8
+ def generate_google_file_picker_url(context: ToolContext) -> dict:
9
+ """Generate a Google File Picker URL for user-driven file selection and authorization.
10
+
11
+ Generates a URL that directs the end-user to a Google File Picker interface where
12
+ where they can select or upload Google Drive files. Users can grant permission to access their
13
+ Drive files, providing a secure and authorized way to interact with their files.
14
+
15
+ This is particularly useful when prior tools (e.g., those accessing or modifying
16
+ Google Docs, Google Sheets, etc.) encountered failures due to file non-existence
17
+ (Requested entity was not found) or permission errors. Once the user completes the file
18
+ picker flow, the prior tool can be retried.
19
+
20
+ Returns:
21
+ A dictionary containing the URL and instructions for the llm to instruct the user.
22
+ """
23
+ client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID)
24
+ client_id_parts = client_id.split("-")
25
+ if not client_id_parts:
26
+ raise ToolExecutionError(
27
+ message="Invalid Google Client ID",
28
+ developer_message=f"Google Client ID '{client_id}' is not valid",
29
+ )
30
+ app_id = client_id_parts[0]
31
+ cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/")
32
+
33
+ config = {
34
+ "auth": {
35
+ "client_id": client_id,
36
+ "app_id": app_id,
37
+ },
38
+ }
39
+ config_json = json.dumps(config)
40
+ config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8")
41
+ url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}"
42
+
43
+ return {
44
+ "url": url,
45
+ "llm_instructions": (
46
+ "Instruct the user to click the following link to open the Google Drive File Picker. "
47
+ f"This will allow them to select files and grant access permissions: {url}"
48
+ ),
49
+ }
@@ -0,0 +1,5 @@
1
+ optional_file_picker_instructions_template = (
2
+ "Ensure the user knows that they have the option to select and grant access permissions to "
3
+ "additional documents via the Google Drive File Picker. "
4
+ "The user can pick additional documents via the following link: {url}"
5
+ )
@@ -0,0 +1,18 @@
1
+ from arcade_google_slides.tools.comment import (
2
+ comment_on_presentation,
3
+ list_presentation_comments,
4
+ )
5
+ from arcade_google_slides.tools.create import create_presentation, create_slide
6
+ from arcade_google_slides.tools.get import get_presentation_as_markdown
7
+ from arcade_google_slides.tools.search import (
8
+ search_presentations,
9
+ )
10
+
11
+ __all__ = [
12
+ "create_presentation",
13
+ "create_slide",
14
+ "get_presentation_as_markdown",
15
+ "search_presentations",
16
+ "comment_on_presentation",
17
+ "list_presentation_comments",
18
+ ]